Compare commits

..
Author SHA1 Message Date
dni ⚡ 2320018302 fixup! 2025-11-26 14:26:16 +01:00
dni ⚡ 0dc0725a9b fix: change button label on wallet 2025-11-26 14:25:21 +01:00
200 changed files with 10166 additions and 15227 deletions
-7
View File
@@ -23,10 +23,3 @@ mypy.ini
package-lock.json package-lock.json
package.json package.json
pytest.ini pytest.ini
.mypy_cache
.github
.pytest_cache
.vscode
bin
dist
+3 -4
View File
@@ -6,10 +6,6 @@
# The following settings are ONLY set in your .env file. # The following settings are ONLY set in your .env file.
# They are NOT managed by the Admin UI and are not stored in the database. # They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token ===
# if set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken"
# === Logging and Development === # === Logging and Development ===
DEBUG=False DEBUG=False
@@ -22,6 +18,7 @@ ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file # https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB" LOG_ROTATION="100 MB"
LOG_RETENTION="3 months" LOG_RETENTION="3 months"
# for database cleanup commands # for database cleanup commands
# CLEANUP_WALLETS_DAYS=90 # CLEANUP_WALLETS_DAYS=90
@@ -190,6 +187,7 @@ BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon" BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible # HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
BOLTZ_CLIENT_WALLET="lnbits"
# StrikeWallet # StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1 STRIKE_API_ENDPOINT=https://api.strike.me/v1
@@ -335,3 +333,4 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
###################################### ######################################
###### Logging and Development ####### ###### Logging and Development #######
###################################### ######################################
+1 -8
View File
@@ -75,14 +75,7 @@ jobs:
strategy: strategy:
matrix: matrix:
python-version: ["3.10"] python-version: ["3.10"]
backend-wallet-class: backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
- BoltzWallet
- LndRestWallet
- LndWallet
- CoreLightningWallet
- CoreLightningRestWallet
- LNbitsWallet
- EclairWallet
with: with:
custom-pytest: "uv run pytest tests/regtest" custom-pytest: "uv run pytest tests/regtest"
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
-1
View File
@@ -62,4 +62,3 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache
build-args: LNBITS_TAG=${{ inputs.tag }}
+2 -4
View File
@@ -40,8 +40,8 @@ jobs:
run: | run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker cd docker
chmod +x ./start-regtest chmod +x ./tests
./start-regtest ./tests
sudo chmod -R a+rwx . sudo chmod -R a+rwx .
- name: Run pytest - name: Run pytest
@@ -63,8 +63,6 @@ jobs:
LNBITS_ENDPOINT: http://localhost:5001 LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee" LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082 ECLAIR_URL: http://127.0.0.1:8082
BOLTZ_CLIENT_ENDPOINT: 127.0.0.1:9002
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000 LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
ECLAIR_PASS: lnbits ECLAIR_PASS: lnbits
+7 -41
View File
@@ -10,29 +10,7 @@ permissions:
jobs: jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits'
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
@@ -41,26 +19,14 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi: pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.10 - name: Build and publish to pypi
uses: actions/setup-python@v5 uses: JRubics/poetry-publish@v1.15
with: with:
python-version: "3.10" pypi_token: ${{ secrets.PYPI_API_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+9 -23
View File
@@ -14,7 +14,7 @@ jobs:
release: release:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs: outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Create github release - name: Create github release
@@ -22,18 +22,10 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
run: | run: |
gh release create "$tag" --generate-notes --draft upload_url=$(gh release create "$tag" --generate-notes --draft --json upload_url -q '.upload_url')
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT" echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker: docker:
if: github.repository == 'lnbits/lnbits'
needs: [ release ] needs: [ release ]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
@@ -43,7 +35,6 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest: docker-latest:
if: github.repository == 'lnbits/lnbits'
needs: [ release ] needs: [ release ]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
@@ -53,22 +44,17 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi: pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Install dependencies for building secp256k1
run: |
sudo apt-get update
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.10 - name: Build and publish to pypi
uses: actions/setup-python@v5 uses: JRubics/poetry-publish@v1.15
with: with:
python-version: "3.10" pypi_token: ${{ secrets.PYPI_API_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage: appimage:
needs: [ release ] needs: [ release ]
+1 -1
View File
@@ -23,7 +23,7 @@ repos:
- id: ruff - id: ruff
args: [ --fix, --exit-non-zero-on-fix ] args: [ --fix, --exit-non-zero-on-fix ]
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
rev: v3.7.4 rev: v3.6.2
hooks: hooks:
- id: prettier - id: prettier
types_or: [css, javascript, html, json] types_or: [css, javascript, html, json]
+2 -3
View File
@@ -1,7 +1,6 @@
ARG LNBITS_TAG=latest
FROM boltz/boltz-client:latest AS boltz FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:${LNBITS_TAG}
FROM lnbits/lnbits:latest
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/ COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd RUN ls -l /usr/local/bin/boltzd
-2
View File
@@ -1,6 +1,5 @@
from .core.services import create_invoice, pay_invoice from .core.services import create_invoice, pay_invoice
from .decorators import ( from .decorators import (
check_account_exists,
check_admin, check_admin,
check_super_user, check_super_user,
check_user_exists, check_user_exists,
@@ -12,7 +11,6 @@ from .exceptions import InvoiceError, PaymentError
__all__ = [ __all__ = [
"InvoiceError", "InvoiceError",
"PaymentError", "PaymentError",
"check_account_exists",
"check_admin", "check_admin",
"check_super_user", "check_super_user",
"check_user_exists", "check_user_exists",
+9 -19
View File
@@ -1,4 +1,3 @@
from datetime import datetime, timezone
from time import time from time import time
from typing import Any from typing import Any
@@ -34,9 +33,9 @@ async def get_payment(checking_id: str, conn: Connection | None = None) -> Payme
async def get_standalone_payment( async def get_standalone_payment(
checking_id_or_hash: str, checking_id_or_hash: str,
conn: Connection | None = None,
incoming: bool | None = False, incoming: bool | None = False,
wallet_id: str | None = None, wallet_id: str | None = None,
conn: Connection | None = None,
) -> Payment | None: ) -> Payment | None:
clause: str = "checking_id = :checking_id OR payment_hash = :hash" clause: str = "checking_id = :checking_id OR payment_hash = :hash"
values = { values = {
@@ -47,7 +46,7 @@ async def get_standalone_payment(
clause = f"({clause}) AND amount > 0" clause = f"({clause}) AND amount > 0"
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments: if not wallet or not wallet.can_view_payments:
return None return None
values["wallet_id"] = wallet.source_wallet_id values["wallet_id"] = wallet.source_wallet_id
@@ -70,7 +69,7 @@ async def get_standalone_payment(
async def get_wallet_payment( async def get_wallet_payment(
wallet_id: str, payment_hash: str, conn: Connection | None = None wallet_id: str, payment_hash: str, conn: Connection | None = None
) -> Payment | None: ) -> Payment | None:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments: if not wallet or not wallet.can_view_payments:
return None return None
payment = await (conn or db).fetchone( payment = await (conn or db).fetchone(
@@ -125,6 +124,7 @@ async def get_payments_paginated( # noqa: C901
Filters payments to be returned by: Filters payments to be returned by:
- complete | pending | failed | outgoing | incoming. - complete | pending | failed | outgoing | incoming.
""" """
values: dict[str, Any] = { values: dict[str, Any] = {
"time": since, "time": since,
} }
@@ -134,7 +134,7 @@ async def get_payments_paginated( # noqa: C901
clause.append(f"time > {db.timestamp_placeholder('time')}") clause.append(f"time > {db.timestamp_placeholder('time')}")
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments: if not wallet or not wallet.can_view_payments:
return Page(data=[], total=0) return Page(data=[], total=0)
@@ -305,16 +305,8 @@ async def update_payment_checking_id(
checking_id: str, new_checking_id: str, conn: Connection | None = None checking_id: str, new_checking_id: str, conn: Connection | None = None
) -> None: ) -> None:
await (conn or db).execute( await (conn or db).execute(
f""" "UPDATE apipayments SET checking_id = :new_id WHERE checking_id = :old_id",
UPDATE apipayments {"new_id": new_checking_id, "old_id": checking_id},
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
WHERE checking_id = :old_id
""", # noqa: S608
{
"new_id": new_checking_id,
"old_id": checking_id,
"now": int(time()),
},
) )
@@ -323,7 +315,6 @@ async def update_payment(
new_checking_id: str | None = None, new_checking_id: str | None = None,
conn: Connection | None = None, conn: Connection | None = None,
) -> None: ) -> None:
payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update( await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id" "apipayments", payment, "WHERE checking_id = :checking_id"
) )
@@ -335,7 +326,6 @@ async def get_payments_history(
wallet_id: str | None = None, wallet_id: str | None = None,
group: DateTrunc = "day", group: DateTrunc = "day",
filters: Filters | None = None, filters: Filters | None = None,
conn: Connection | None = None,
) -> list[PaymentHistoryPoint]: ) -> list[PaymentHistoryPoint]:
if not filters: if not filters:
filters = Filters() filters = Filters()
@@ -371,13 +361,13 @@ async def get_payments_history(
filters.values(values), filters.values(values),
) )
if wallet_id: if wallet_id:
wallet = await get_wallet(wallet_id, conn=conn) wallet = await get_wallet(wallet_id)
if not wallet or not wallet.can_view_payments: if not wallet or not wallet.can_view_payments:
return [] return []
balance = wallet.balance_msat balance = wallet.balance_msat
values["wallet_id"] = wallet.source_wallet_id values["wallet_id"] = wallet.source_wallet_id
else: else:
balance = await get_total_balance(conn=conn) balance = await get_total_balance()
# since we dont know the balance at the starting point, # since we dont know the balance at the starting point,
# we take the current balance and walk backwards # we take the current balance and walk backwards
+33 -92
View File
@@ -4,12 +4,10 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from lnbits.core.crud.extensions import get_user_active_extensions_ids from lnbits.core.crud.extensions import get_user_active_extensions_ids
from lnbits.core.crud.wallets import clear_wallet_cache, create_wallet, get_wallets from lnbits.core.crud.wallets import create_wallet, get_wallets
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import UserAcls from lnbits.core.models import UserAcls
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Filters, Page
from lnbits.helpers import sha256s
from lnbits.utils.cache import cache
from ..models import ( from ..models import (
Account, Account,
@@ -32,9 +30,9 @@ async def create_account(
return account return account
async def update_account(account: Account, conn: Connection | None = None) -> Account: async def update_account(account: Account) -> Account:
account.updated_at = datetime.now(timezone.utc) account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account) await db.update("accounts", account)
return account return account
@@ -43,7 +41,6 @@ async def delete_account(user_id: str, conn: Connection | None = None) -> None:
"DELETE from accounts WHERE id = :user", "DELETE from accounts WHERE id = :user",
{"user": user_id}, {"user": user_id},
) )
await clear_user_id_cache(user_id)
async def get_accounts( async def get_accounts(
@@ -72,7 +69,6 @@ async def get_accounts(
accounts.email, accounts.email,
accounts.pubkey, accounts.pubkey,
accounts.external_id, accounts.external_id,
accounts.activated,
SUM(COALESCE(( SUM(COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0)) as balance_msat, ), 0)) as balance_msat,
@@ -97,18 +93,12 @@ async def get_accounts(
) )
async def get_account( async def get_account(user_id: str, conn: Connection | None = None) -> Account | None:
user_id: str, active_only: bool = True, conn: Connection | None = None
) -> Account | None:
if len(user_id) == 0: if len(user_id) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" "SELECT * FROM accounts WHERE id = :id",
SELECT * FROM accounts {"id": user_id},
WHERE id = :id AND (activated = true OR activated = :activated)
""",
{"id": user_id, "activated": active_only},
Account, Account,
) )
@@ -134,98 +124,71 @@ async def delete_accounts_no_wallets(
async def get_account_by_username( async def get_account_by_username(
username: str, active_only: bool = True, conn: Connection | None = None username: str, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(username) == 0: if len(username) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" "SELECT * FROM accounts WHERE LOWER(username) = :username",
SELECT * FROM accounts {"username": username.lower()},
WHERE
LOWER(username) = :username
AND (activated = true OR activated = :activated)
""",
{"username": username.lower(), "activated": active_only},
Account, Account,
) )
async def get_account_by_pubkey( async def get_account_by_pubkey(
pubkey: str, active_only: bool = True, conn: Connection | None = None pubkey: str, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" "SELECT * FROM accounts WHERE LOWER(pubkey) = :pubkey",
SELECT * FROM accounts {"pubkey": pubkey.lower()},
WHERE
LOWER(pubkey) = :pubkey
AND (activated = true OR activated = :activated)
""",
{"pubkey": pubkey.lower(), "activated": active_only},
Account, Account,
) )
async def get_account_by_email( async def get_account_by_email(
email: str, active_only: bool = True, conn: Connection | None = None email: str, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(email) == 0: if len(email) == 0:
return None return None
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" "SELECT * FROM accounts WHERE LOWER(email) = :email",
SELECT * FROM accounts {"email": email.lower()},
WHERE
LOWER(email) = :email
AND (activated = true OR activated = :activated)
""",
{"email": email.lower(), "activated": active_only},
Account, Account,
) )
async def get_account_by_username_or_email( async def get_account_by_username_or_email(
username_or_email: str, username_or_email: str, conn: Connection | None = None
active_only: bool = True,
conn: Connection | None = None,
) -> Account | None: ) -> Account | None:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE LOWER(email) = :value or LOWER(username) = :value
(LOWER(email) = :value or LOWER(username) = :value)
AND (activated = true OR activated = :activated)
""", """,
{"value": username_or_email.lower(), "activated": active_only}, {"value": username_or_email.lower()},
Account, Account,
) )
async def get_user( async def get_user(user_id: str, conn: Connection | None = None) -> User | None:
user_id: str, active_only: bool = True, conn: Connection | None = None account = await get_account(user_id, conn)
) -> User | None: if not account:
async with db.reuse_conn(conn) if conn else db.connect() as conn: return None
account = await get_account(user_id, active_only, conn=conn) return await get_user_from_account(account, conn)
if not account:
return None
return await get_user_from_account(account, conn=conn)
async def get_user_from_account( async def get_user_from_account(
account: Account, conn: Connection | None = None account: Account, conn: Connection | None = None
) -> User | None: ) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn: extensions = await get_user_active_extensions_ids(account.id, conn=conn)
extensions = await get_user_active_extensions_ids(account.id, conn=conn) wallets = await get_wallets(account.id, deleted=False, conn=conn)
wallets = await get_wallets(account.id, deleted=False, conn=conn)
if len(wallets) == 0: if len(wallets) == 0:
wallet = await create_wallet(user_id=account.id, conn=conn) wallet = await create_wallet(user_id=account.id, conn=conn)
wallets.append(wallet) wallets.append(wallet)
return User( return User(
id=account.id, id=account.id,
activated=account.activated,
email=account.email, email=account.email,
username=account.username, username=account.username,
pubkey=account.pubkey, pubkey=account.pubkey,
@@ -239,43 +202,21 @@ async def get_user_from_account(
super_user=account.is_super_user, super_user=account.is_super_user,
fiat_providers=account.fiat_providers, fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None, has_password=account.password_hash is not None,
ui_customization=account.ui_customization or {},
) )
async def update_user_access_control_list( async def update_user_access_control_list(user_acls: UserAcls):
user_acls: UserAcls, conn: Connection | None = None
):
user_acls.updated_at = datetime.now(timezone.utc) user_acls.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", user_acls) await db.update("accounts", user_acls)
async def get_user_access_control_lists( async def get_user_access_control_lists(
user_id: str, active_only: bool = True, conn: Connection | None = None user_id: str, conn: Connection | None = None
) -> UserAcls: ) -> UserAcls:
user_acls = await (conn or db).fetchone( user_acls = await (conn or db).fetchone(
""" "SELECT id, access_control_list FROM accounts WHERE id = :id",
SELECT id, access_control_list FROM accounts {"id": user_id},
WHERE id = :user_id AND (activated = true OR activated = :activated)
""",
{"user_id": user_id, "activated": active_only},
UserAcls, UserAcls,
) )
return user_acls or UserAcls(id=user_id) return user_acls or UserAcls(id=user_id)
async def clear_user_id_cache(user_id: str):
user = await get_user(user_id, active_only=True)
if user:
clear_user_cache(user)
def clear_user_cache(user: User):
user_cache_key: str | None = cache.pop(
f"auth:user:cache_key:{sha256s(user.id)}", None
)
if user_cache_key:
cache.pop(user_cache_key)
for wallet in user.wallets:
clear_wallet_cache(wallet)
+4 -45
View File
@@ -3,10 +3,9 @@ from time import time
from uuid import uuid4 from uuid import uuid4
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models.wallets import BaseWallet, WalletsFilters, WalletType from lnbits.core.models.wallets import WalletsFilters, WalletType
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Filters, Page
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.cache import cache
from ..models import Wallet from ..models import Wallet
@@ -45,14 +44,13 @@ async def update_wallet(
async def delete_wallet( async def delete_wallet(
*,
user_id: str, user_id: str,
wallet_id: str, wallet_id: str,
deleted: bool = True, deleted: bool = True,
conn: Connection | None = None, conn: Connection | None = None,
) -> None: ) -> None:
clear_wallet_id_cache(wallet_id)
now = int(time()) now = int(time())
await (conn or db).execute( await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input) # Timestamp placeholder is safe from SQL injection (not user input)
f""" f"""
@@ -65,7 +63,6 @@ async def delete_wallet(
async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None: async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) -> None:
clear_wallet_id_cache(wallet_id)
await (conn or db).execute( await (conn or db).execute(
"DELETE FROM wallets WHERE id = :wallet", "DELETE FROM wallets WHERE id = :wallet",
{"wallet": wallet_id}, {"wallet": wallet_id},
@@ -75,7 +72,6 @@ async def force_delete_wallet(wallet_id: str, conn: Connection | None = None) ->
async def delete_wallet_by_id( async def delete_wallet_by_id(
wallet_id: str, conn: Connection | None = None wallet_id: str, conn: Connection | None = None
) -> int | None: ) -> int | None:
clear_wallet_id_cache(wallet_id)
now = int(time()) now = int(time())
result = await (conn or db).execute( result = await (conn or db).execute(
# Timestamp placeholder is safe from SQL injection (not user input) # Timestamp placeholder is safe from SQL injection (not user input)
@@ -226,14 +222,11 @@ async def get_wallet_for_key(
) -> Wallet | None: ) -> Wallet | None:
wallet = await (conn or db).fetchone( wallet = await (conn or db).fetchone(
""" """
SELECT wallets.*, COALESCE(( SELECT *, COALESCE((
SELECT balance FROM balances WHERE wallet_id = wallets.id SELECT balance FROM balances WHERE wallet_id = wallets.id
), 0) ), 0)
AS balance_msat FROM wallets AS balance_msat FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id WHERE (adminkey = :key OR inkey = :key) AND deleted = false
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""", """,
{"key": key}, {"key": key},
Wallet, Wallet,
@@ -247,27 +240,6 @@ async def get_wallet_for_key(
return wallet return wallet
async def get_base_wallet_for_key(
key: str,
conn: Connection | None = None,
) -> BaseWallet | None:
wallet = await (conn or db).fetchone(
"""
SELECT wallets.id, "user", wallet_type, adminkey, inkey FROM wallets
INNER JOIN accounts ON wallets.user = accounts.id
WHERE (adminkey = :key OR inkey = :key)
AND deleted = false
AND accounts.activated = true
""",
{"key": key},
BaseWallet,
)
if not wallet:
return None
return wallet
async def get_source_wallet( async def get_source_wallet(
wallet: Wallet, conn: Connection | None = None wallet: Wallet, conn: Connection | None = None
) -> Wallet | None: ) -> Wallet | None:
@@ -298,16 +270,3 @@ async def get_total_balance(conn: Connection | None = None):
result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances") result = await (conn or db).execute("SELECT SUM(balance) as balance FROM balances")
row = result.mappings().first() row = result.mappings().first()
return row.get("balance", 0) or 0 return row.get("balance", 0) or 0
def clear_wallet_id_cache(wallet_id: str):
cached_wallet: BaseWallet | None = cache.pop(f"auth:wallet:{wallet_id}")
if cached_wallet:
cache.pop(f"auth:x-api-key:{cached_wallet.adminkey}")
cache.pop(f"auth:x-api-key:{cached_wallet.inkey}")
def clear_wallet_cache(wallet: Wallet):
cache.pop(f"auth:wallet:{wallet.id}")
cache.pop(f"auth:x-api-key:{wallet.adminkey}")
cache.pop(f"auth:x-api-key:{wallet.inkey}")
-78
View File
@@ -786,81 +786,3 @@ async def m038_add_labels_for_payments(db: Connection):
ALTER TABLE apipayments ADD COLUMN labels TEXT ALTER TABLE apipayments ADD COLUMN labels TEXT
""" """
) )
async def m039_index_payments(db: Connection):
indexes = [
"wallet_id",
"checking_id",
"payment_hash",
"amount",
"fee",
"labels",
"time",
"status",
"memo",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_payments_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_payments_{index} ON apipayments ({index});
"""
)
async def m040_index_wallets(db: Connection):
indexes = [
"id",
"user",
"deleted",
"adminkey",
"inkey",
"wallet_type",
"created_at",
"updated_at",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_wallets_{index} ON wallets ("{index}");
"""
)
async def m042_index_accounts(db: Connection):
indexes = [
"id",
"email",
"username",
"pubkey",
"external_id",
]
for index in indexes:
logger.debug(f"Creating index idx_wallets_{index}...")
await db.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
"""
)
async def m043_add_ui_customization_to_accounts(db: Connection):
"""
Adds ui_customization column to accounts.
Used for server side persistence of UI customization settings.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
async def m044_add_activated_to_accounts(db: Connection):
"""
Adds activated column to accounts.
Used for account activation status.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
+2 -2
View File
@@ -46,7 +46,7 @@ from .users import (
UserAcls, UserAcls,
UserExtra, UserExtra,
) )
from .wallets import CreateWallet, KeyType, Wallet, WalletInfo, WalletTypeInfo from .wallets import BaseWallet, CreateWallet, KeyType, Wallet, WalletTypeInfo
from .webpush import CreateWebPushSubscription, WebPushSubscription from .webpush import CreateWebPushSubscription, WebPushSubscription
__all__ = [ __all__ = [
@@ -57,6 +57,7 @@ __all__ = [
"AuditEntry", "AuditEntry",
"AuditFilters", "AuditFilters",
"BalanceDelta", "BalanceDelta",
"BaseWallet",
"Callback", "Callback",
"CancelInvoice", "CancelInvoice",
"ConversionData", "ConversionData",
@@ -98,7 +99,6 @@ __all__ = [
"UserAcls", "UserAcls",
"UserExtra", "UserExtra",
"Wallet", "Wallet",
"WalletInfo",
"WalletTypeInfo", "WalletTypeInfo",
"WebPushSubscription", "WebPushSubscription",
] ]
+2 -81
View File
@@ -6,13 +6,12 @@ import json
import os import os
import shutil import shutil
import zipfile import zipfile
from asyncio.tasks import create_task
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field from pydantic import BaseModel
from lnbits.helpers import ( from lnbits.helpers import (
download_url, download_url,
@@ -21,7 +20,6 @@ from lnbits.helpers import (
version_parse, version_parse,
) )
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.cache import cache
class ExplicitRelease(BaseModel): class ExplicitRelease(BaseModel):
@@ -269,30 +267,10 @@ class ExtensionRelease(BaseModel):
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]: async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try: try:
github_releases = await cls.fetch_github_releases(org, repo) github_releases = await cls.fetch_github_releases(org, repo)
extension_releases = [ return [
ExtensionRelease.from_github_release(f"{org}/{repo}", r) ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases for r in github_releases
] ]
for release in extension_releases:
if not release.details_link:
continue
try:
config = await ExtensionConfig.fetch_github_release_config(
org, repo, release.version
)
except Exception as e:
logger.warning(e)
config = None
if not config:
continue
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
return extension_releases
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
return [] return []
@@ -628,37 +606,6 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_installable_extensions( async def get_installable_extensions(
cls, post_refresh_cache: bool = False
) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = []
cache_key = "extensions:installable"
cache_value = cache.value(cache_key)
if not cache_value:
extension_list = await cls._get_installable_extensions()
cache.set(cache_key, extension_list, expiry=3600) # one hour
return extension_list
if cache_value.older_than(10 * 60) or post_refresh_cache:
# refresh cache in background if older than 10 minutes or requested
create_task(cls._refresh_installable_extensions_cache())
extension_list = cache_value.value # type: ignore
return extension_list
@classmethod
async def _refresh_installable_extensions_cache(
cls,
) -> None:
cache_key = "extensions:installable"
extension_list: list[InstallableExtension] = (
await cls._get_installable_extensions()
)
cache.set(cache_key, extension_list, expiry=3600)
@classmethod
async def _get_installable_extensions(
cls, cls,
) -> list[InstallableExtension]: ) -> list[InstallableExtension]:
extension_list: list[InstallableExtension] = [] extension_list: list[InstallableExtension] = []
@@ -797,32 +744,6 @@ class ExtensionDetailsRequest(BaseModel):
version: str version: str
class ExtensionReviewsStatus(BaseModel):
tag: str
avg_rating: float
review_count: int
class CreateExtensionReview(BaseModel):
tag: str
name: str | None = Field(None)
rating: int = Field(..., ge=0, le=1000)
comment: str | None = Field(None)
class ExtensionReviewPaymentRequest(BaseModel):
payment_hash: str
payment_request: str
class ExtensionReview(BaseModel):
id: str
name: str | None = Field(default=None)
tag: str | None = Field(default=None)
rating: int = Field(default=0, ge=0, le=1000)
comment: str | None = Field(default=None)
async def github_api_get(url: str, error_msg: str | None) -> Any: async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token: if settings.lnbits_ext_github_token:
-25
View File
@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json import json
import random
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Literal from typing import Any, Literal
@@ -57,29 +55,6 @@ class DataField(BaseModel):
field_type += ' = "sat"' field_type += ' = "sat"'
return f"{field_name}: {field_type}" return f"{field_name}: {field_type}"
def field_to_random_value(self) -> str:
field_name = camel_to_snake(self.name)
field_value: Any = f'"{self.type}"'
if self.type == "json":
field_value = '"{}"'
elif self.type == "wallet":
field_value = f'"{uuid.uuid4()}"'
elif self.type == "currency":
field_value = '"sat"'
elif self.type in ["str", "text"]:
field_value = f'"{field_name}_{urlsafe_short_hash()}"'
elif self.type == "int":
field_value = random.randint(1, 100) # noqa: S311
elif self.type == "float":
field_value = random.uniform(1.0, 100.0) # noqa: S311
elif self.type == "bool":
field_value = random.choice([True, False]) # noqa: S311
elif self.type == "datetime":
random_days = random.randint(-30, 30) # noqa: S311
random_date = datetime.now(timezone.utc) - timedelta(days=random_days)
field_value = f"""datetime.fromisoformat("{random_date.isoformat()}")"""
return f"{field_name} = {field_value},"
def field_to_js(self) -> str: def field_to_js(self) -> str:
field_name = camel_to_snake(self.name) field_name = camel_to_snake(self.name)
default_value = "null" default_value = "null"
+54 -19
View File
@@ -6,16 +6,23 @@ from typing import Literal
from fastapi import Query from fastapi import Query
from lnurl import LnurlWithdrawResponse from lnurl import LnurlWithdrawResponse
from loguru import logger
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import ( from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus, FiatPaymentStatus,
FiatPaymentSuccessStatus,
) )
from lnbits.utils.exchange_rates import allowed_currencies from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
PaymentFailedStatus,
PaymentPendingStatus,
PaymentStatus, PaymentStatus,
PaymentSuccessStatus,
) )
@@ -123,23 +130,59 @@ class Payment(BaseModel):
"fiat_" "fiat_"
) )
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_status( async def check_status(
self, skip_internal_payment_notifications: bool | None = False self, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus: ) -> PaymentStatus:
logger.warning("payment.check_status() is deprecated.") if self.is_internal:
from lnbits.core.services.payments import check_payment_status if self.success:
return PaymentSuccessStatus()
if self.failed:
return PaymentFailedStatus()
if self.is_in and self.fiat_provider:
fiat_status = await self.check_fiat_status(
skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if self.is_out:
status = await funding_source.get_payment_status(self.checking_id)
else:
status = await funding_source.get_invoice_status(self.checking_id)
return status
return await check_payment_status(self, skip_internal_payment_notifications)
# DEPRECATED: in v1.5.0, use service check_payment_status instead
async def check_fiat_status( async def check_fiat_status(
self, skip_internal_payment_notifications: bool | None = False self, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus: ) -> FiatPaymentStatus:
logger.warning("payment.check_fiat_status() is deprecated.") if not self.is_internal:
from lnbits.core.services.fiat_providers import check_fiat_status return FiatPaymentPendingStatus()
if self.success:
return FiatPaymentSuccessStatus()
if self.failed:
return FiatPaymentFailedStatus()
return await check_fiat_status(self, skip_internal_payment_notifications) if not self.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = self.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(self.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(self.checking_id)
return fiat_status
class PaymentFilters(FilterModel): class PaymentFilters(FilterModel):
@@ -153,15 +196,7 @@ class PaymentFilters(FilterModel):
"labels", "labels",
] ]
__sort_fields__ = [ __sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
"created_at",
"updated_at",
"amount",
"fee",
"memo",
"time",
"tag",
]
status: str | None status: str | None
tag: str | None tag: str | None
+1 -19
View File
@@ -172,23 +172,14 @@ class UserAcls(BaseModel):
return None return None
class AccountId(BaseModel): class Account(BaseModel):
id: str id: str
@property
def is_admin_id(self) -> bool:
return settings.is_admin_user(self.id)
class Account(AccountId):
activated: bool = True
external_id: str | None = None # for external account linking external_id: str | None = None # for external account linking
username: str | None = None username: str | None = None
password_hash: str | None = None password_hash: str | None = None
pubkey: str | None = None pubkey: str | None = None
email: str | None = None email: str | None = None
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -203,10 +194,6 @@ class Account(AccountId):
self.is_admin = settings.is_admin_user(self.id) self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id) self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
@property
def has_password(self) -> bool:
return self.password_hash is not None
def hash_password(self, password: str) -> str: def hash_password(self, password: str) -> str:
"""sets and returns the hashed password""" """sets and returns the hashed password"""
salt = gensalt() salt = gensalt()
@@ -242,7 +229,6 @@ class Account(AccountId):
class AccountOverview(Account): class AccountOverview(Account):
activated: bool = True
transaction_count: int | None = 0 transaction_count: int | None = 0
wallet_count: int | None = 0 wallet_count: int | None = 0
balance_msat: int | None = 0 balance_msat: int | None = 0
@@ -278,7 +264,6 @@ class AccountFilters(FilterModel):
class User(BaseModel): class User(BaseModel):
id: str id: str
activated: bool = True
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
email: str | None = None email: str | None = None
@@ -292,7 +277,6 @@ class User(BaseModel):
fiat_providers: list[str] = [] fiat_providers: list[str] = []
has_password: bool = False has_password: bool = False
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
@property @property
def wallet_ids(self) -> list[str]: def wallet_ids(self) -> list[str]:
@@ -318,7 +302,6 @@ class RegisterUser(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20) username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50) password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50) password_repeat: str = Query(default=..., min_length=8, max_length=50)
invitation_code: str | None = Query(default=None, min_length=1, max_length=256)
class CreateUser(BaseModel): class CreateUser(BaseModel):
@@ -362,7 +345,6 @@ class UpdateSuperuserPassword(BaseModel):
username: str = Query(default=..., min_length=2, max_length=20) username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50) password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50) password_repeat: str = Query(default=..., min_length=8, max_length=50)
first_install_token: str | None = Query(None)
class LoginUsr(BaseModel): class LoginUsr(BaseModel):
+4 -13
View File
@@ -11,7 +11,7 @@ from lnbits.db import FilterModel
from lnbits.settings import settings from lnbits.settings import settings
class WalletInfo(BaseModel): class BaseWallet(BaseModel):
id: str id: str
name: str name: str
adminkey: str adminkey: str
@@ -110,16 +110,13 @@ class WalletExtra(BaseModel):
] ]
class BaseWallet(BaseModel): class Wallet(BaseModel):
id: str id: str
user: str user: str
wallet_type: str = WalletType.LIGHTNING.value name: str
adminkey: str adminkey: str
inkey: str inkey: str
wallet_type: str = WalletType.LIGHTNING.value
class Wallet(BaseWallet):
name: str
# Must be set only for shared wallets # Must be set only for shared wallets
shared_wallet_id: str | None = None shared_wallet_id: str | None = None
deleted: bool = False deleted: bool = False
@@ -233,12 +230,6 @@ class WalletTypeInfo:
wallet: Wallet wallet: Wallet
@dataclass
class BaseWalletTypeInfo:
key_type: KeyType
wallet: BaseWallet
class WalletsFilters(FilterModel): class WalletsFilters(FilterModel):
__search_fields__ = ["id", "name", "currency"] __search_fields__ = ["id", "name", "currency"]
-4
View File
@@ -1,4 +1,3 @@
from .fiat_providers import check_fiat_status
from .funding_source import ( from .funding_source import (
get_balance_delta, get_balance_delta,
switch_to_voidwallet, switch_to_voidwallet,
@@ -8,7 +7,6 @@ from .notifications import enqueue_admin_notification, send_payment_notification
from .payments import ( from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
cancel_hold_invoice, cancel_hold_invoice,
check_payment_status,
check_transaction_status, check_transaction_status,
check_wallet_limits, check_wallet_limits,
create_fiat_invoice, create_fiat_invoice,
@@ -42,8 +40,6 @@ __all__ = [
"calculate_fiat_amounts", "calculate_fiat_amounts",
"cancel_hold_invoice", "cancel_hold_invoice",
"check_admin_settings", "check_admin_settings",
"check_fiat_status",
"check_payment_status",
"check_transaction_status", "check_transaction_status",
"check_wallet_limits", "check_wallet_limits",
"check_webpush_settings", "check_webpush_settings",
+13 -27
View File
@@ -3,7 +3,6 @@ import io
from uuid import uuid4 from uuid import uuid4
from fastapi import UploadFile from fastapi import UploadFile
from loguru import logger
from PIL import Image from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count from lnbits.core.crud.assets import create_asset, get_user_assets_count
@@ -17,7 +16,7 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types: if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.") raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id): if user_id not in settings.lnbits_assets_no_limit_users:
user_assets_count = await get_user_assets_count(user_id) user_assets_count = await get_user_assets_count(user_id)
if user_assets_count >= settings.lnbits_max_assets_per_user: if user_assets_count >= settings.lnbits_max_assets_per_user:
raise ValueError( raise ValueError(
@@ -30,7 +29,17 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded." f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
) )
thumb_buffer = thumbnail_from_bytes(contents) image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
asset = Asset( asset = Asset(
id=uuid4().hex, id=uuid4().hex,
@@ -39,32 +48,9 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
is_public=is_public, is_public=is_public,
name=file.filename or "unnamed", name=file.filename or "unnamed",
size_bytes=len(contents), size_bytes=len(contents),
thumbnail_base64=( thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
if thumb_buffer
else None
),
data=contents, data=contents,
) )
await create_asset(asset) await create_asset(asset)
return asset return asset
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try:
image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
return thumb_buffer
except Exception as exc:
logger.warning(f"Failed to create thumbnail: {exc}")
return None
+2 -3
View File
@@ -16,7 +16,6 @@ from lnbits.core.crud.extensions import (
update_installed_extension, update_installed_extension,
) )
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection
from lnbits.settings import settings from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
@@ -150,9 +149,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
async def get_valid_extensions( async def get_valid_extensions(
include_deactivated: bool | None = True, conn: Connection | None = None include_deactivated: bool | None = True,
) -> list[Extension]: ) -> list[Extension]:
installed_extensions = await get_installed_extensions(conn=conn) installed_extensions = await get_installed_extensions()
valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions] valid_extensions = [Extension.from_installable_ext(e) for e in installed_extensions]
if include_deactivated: if include_deactivated:
+11 -44
View File
@@ -20,7 +20,6 @@ from lnbits.helpers import (
camel_to_words, camel_to_words,
download_url, download_url,
lowercase_first_letter, lowercase_first_letter,
snake_to_camel,
) )
from lnbits.settings import settings from lnbits.settings import settings
@@ -196,29 +195,13 @@ def _copy_ext_stub_to_build_dir(
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id) ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
shutil.rmtree(ext_build_dir, True) shutil.rmtree(ext_build_dir, True)
shutil.copytree( shutil.copytree(ext_stub_cache_dir, ext_build_dir)
ext_stub_cache_dir,
ext_build_dir,
ignore=shutil.ignore_patterns(
"__pycache__",
".git",
".env",
".venv",
"*.env",
"*.log",
"node_modules",
".mypy_cache",
".pytest_cache",
".ruff_cache",
),
)
return ext_build_dir return ext_build_dir
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None: def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
parsed_data = _parse_extension_data(data) parsed_data = _parse_extension_data(data)
test_files = [f"tests/{p.name}" for p in Path(ext_stub_dir, "tests").glob("*.py")] for py_file in py_files:
for py_file in py_files + test_files:
template_path = Path(ext_stub_dir, py_file).as_posix() template_path = Path(ext_stub_dir, py_file).as_posix()
rederer = _render_file(template_path, parsed_data) rederer = _render_file(template_path, parsed_data)
with open(template_path, "w", encoding="utf-8") as f: with open(template_path, "w", encoding="utf-8") as f:
@@ -226,7 +209,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
_remove_lines_with_string(template_path, remove_line_marker) _remove_lines_with_string(template_path, remove_line_marker)
template_path = Path(ext_stub_dir, "static", "index.js").as_posix() template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
rederer = _render_file( rederer = _render_file(
template_path, {"preview": data.preview_action, **parsed_data} template_path, {"preview": data.preview_action, **parsed_data}
) )
@@ -251,7 +234,9 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"settingsFormDialog.data", "settingsFormDialog.data",
ext_stub_dir, ext_stub_dir,
) )
template_path = Path(ext_stub_dir, "static", "index.vue").as_posix() template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
).as_posix()
rederer = _render_file( rederer = _render_file(
template_path, template_path,
{ {
@@ -278,12 +263,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"publicClientData", "publicClientData",
ext_stub_dir, ext_stub_dir,
) )
public_template_path = Path(ext_stub_dir, "static", "public_page.vue") public_template_path = Path(
public_component_path = Path(ext_stub_dir, "static", "public_page.js") ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
)
template_path = public_template_path.as_posix() template_path = public_template_path.as_posix()
if not data.public_page.has_public_page: if not data.public_page.has_public_page:
public_template_path.unlink(missing_ok=True) public_template_path.unlink(missing_ok=True)
public_component_path.unlink(missing_ok=True)
else: else:
rederer = _render_file( rederer = _render_file(
template_path, template_path,
@@ -323,10 +308,8 @@ def zip_directory(source_dir, zip_path):
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None: def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
extension_dir_path = extension_dir.as_posix() extension_dir_path = extension_dir.as_posix()
# the order of fields is important, do not chage
rename_values = { rename_values = {
"extension_builder_stub_name": data.name, "extension_builder_stub_name": data.name,
"extension_builder_stub_camel_name": snake_to_camel(data.id, True),
"extension_builder_stub_short_description": data.short_description or "", "extension_builder_stub_short_description": data.short_description or "",
"extension_builder_stub": data.id, "extension_builder_stub": data.id,
"OwnerData": data.owner_data.name, "OwnerData": data.owner_data.name,
@@ -345,7 +328,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
directory=extension_dir_path, directory=extension_dir_path,
old_text=old_text, old_text=old_text,
new_text=new_text, new_text=new_text,
file_extensions=[".py", ".js", ".vue", ".html", ".md", ".json", ".toml"], file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
) )
_rename_files_and_dirs_in_directory( _rename_files_and_dirs_in_directory(
@@ -423,7 +406,6 @@ def _parse_extension_data(data: ExtensionData) -> dict:
"owner_data": { "owner_data": {
"name": data.owner_data.name, "name": data.owner_data.name,
"editable": data.owner_data.editable, "editable": data.owner_data.editable,
"fields": [field.name for field in data.owner_data.fields],
"js_fields": [ "js_fields": [
field.field_to_js() field.field_to_js()
for field in data.owner_data.fields for field in data.owner_data.fields
@@ -450,18 +432,6 @@ def _parse_extension_data(data: ExtensionData) -> dict:
], ],
"db_fields": [field.field_to_db() for field in data.owner_data.fields], "db_fields": [field.field_to_db() for field in data.owner_data.fields],
"all_fields": [field.field_to_py() for field in data.owner_data.fields], "all_fields": [field.field_to_py() for field in data.owner_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.owner_data.fields
],
"public_fields": [
field.field_to_py()
for field in data.owner_data.fields
if field.name
in [
data.public_page.owner_data_fields.name,
data.public_page.owner_data_fields.description,
]
],
}, },
"client_data": { "client_data": {
"name": data.client_data.name, "name": data.client_data.name,
@@ -487,9 +457,6 @@ def _parse_extension_data(data: ExtensionData) -> dict:
], ],
"db_fields": [field.field_to_db() for field in data.client_data.fields], "db_fields": [field.field_to_db() for field in data.client_data.fields],
"all_fields": [field.field_to_py() for field in data.client_data.fields], "all_fields": [field.field_to_py() for field in data.client_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.client_data.fields
],
}, },
"settings_data": { "settings_data": {
"enabled": data.settings_data.enabled, "enabled": data.settings_data.enabled,
@@ -569,7 +536,7 @@ def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
logger.warning(f"Failed to rename directory {old_dir_path}: {e}") logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
def _is_excluded_dir(path: str) -> bool: def _is_excluded_dir(path):
for excluded_dir in excluded_dirs: for excluded_dir in excluded_dirs:
if path.startswith(excluded_dir): if path.startswith(excluded_dir):
return True return True
-99
View File
@@ -1,9 +1,7 @@
import hashlib import hashlib
import hmac import hmac
import json
import time import time
import httpx
from loguru import logger from loguru import logger
from lnbits.core.crud import get_wallet from lnbits.core.crud import get_wallet
@@ -12,12 +10,6 @@ from lnbits.core.models import CreatePayment, Payment, PaymentState
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.db import Connection from lnbits.db import Connection
from lnbits.fiat import get_fiat_provider from lnbits.fiat import get_fiat_provider
from lnbits.fiat.base import (
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
)
from lnbits.settings import settings from lnbits.settings import settings
@@ -35,40 +27,6 @@ async def handle_fiat_payment_confirmation(
logger.warning(e) logger.warning(e)
async def check_fiat_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> FiatPaymentStatus:
if not payment.is_internal:
return FiatPaymentPendingStatus()
if payment.success:
return FiatPaymentSuccessStatus()
if payment.failed:
return FiatPaymentFailedStatus()
if not payment.fiat_provider:
return FiatPaymentPendingStatus()
checking_id = payment.extra.get("fiat_checking_id")
if not checking_id:
return FiatPaymentPendingStatus()
fiat_provider = await get_fiat_provider(payment.fiat_provider)
if not fiat_provider:
return FiatPaymentPendingStatus()
fiat_status = await fiat_provider.get_invoice_status(checking_id)
if skip_internal_payment_notifications:
return fiat_status
if fiat_status.success:
# notify receivers asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(payment.checking_id)
return fiat_status
def check_stripe_signature( def check_stripe_signature(
payload: bytes, payload: bytes,
sig_header: str | None, sig_header: str | None,
@@ -112,63 +70,6 @@ def check_stripe_signature(
raise ValueError("Stripe signature verification failed.") raise ValueError("Stripe signature verification failed.")
async def verify_paypal_webhook(headers, payload: bytes):
"""
Validate PayPal webhook signatures using the PayPal verify API.
"""
webhook_id = settings.paypal_webhook_id
if not webhook_id:
logger.warning("PayPal webhook ID not set; skipping verification.")
return
required_headers = {
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
"PAYPAL-TRANSMISSION-TIME": headers.get("PAYPAL-TRANSMISSION-TIME"),
"PAYPAL-TRANSMISSION-SIG": headers.get("PAYPAL-TRANSMISSION-SIG"),
"PAYPAL-CERT-URL": headers.get("PAYPAL-CERT-URL"),
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
}
if not all(required_headers.values()):
logger.warning("Missing PayPal webhook headers; skipping verification.")
return
try:
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
token_resp = await client.post(
"/v1/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(
settings.paypal_client_id or "",
settings.paypal_client_secret or "",
),
)
token_resp.raise_for_status()
access_token = token_resp.json().get("access_token")
if not access_token:
raise ValueError("PayPal token missing in verification flow.")
verify_resp = await client.post(
"/v1/notifications/verify-webhook-signature",
json={
"auth_algo": required_headers["PAYPAL-AUTH-ALGO"],
"cert_url": required_headers["PAYPAL-CERT-URL"],
"transmission_id": required_headers["PAYPAL-TRANSMISSION-ID"],
"transmission_sig": required_headers["PAYPAL-TRANSMISSION-SIG"],
"transmission_time": required_headers["PAYPAL-TRANSMISSION-TIME"],
"webhook_id": webhook_id,
"webhook_event": json.loads(payload.decode()),
},
headers={"Authorization": f"Bearer {access_token}"},
)
verify_resp.raise_for_status()
verification_status = verify_resp.json().get("verification_status")
if verification_status != "SUCCESS":
raise ValueError("PayPal webhook verification failed.")
except Exception as exc:
logger.warning(exc)
raise ValueError("PayPal webhook cannot be verified.") from exc
async def test_connection(provider: str) -> SimpleStatus: async def test_connection(provider: str) -> SimpleStatus:
""" """
Test the connection to Stripe by checking if the API key is valid. Test the connection to Stripe by checking if the API key is valid.
-8
View File
@@ -1,7 +1,6 @@
import asyncio import asyncio
import json import json
import smtplib import smtplib
from asyncio.tasks import create_task
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from http import HTTPStatus from http import HTTPStatus
@@ -287,13 +286,6 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
logger.error(f"Error dispatching webhook: {e!s}") logger.error(f"Error dispatching webhook: {e!s}")
def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
try:
create_task(send_payment_notification(wallet, payment))
except Exception as e:
logger.warning(f"Error sending payment notification: {e}")
async def send_ws_payment_notification(wallet: Wallet, payment: Payment): async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model # TODO: websocket message should be a clean payment model
# await websocket_manager.send(wallet.inkey, payment.json()) # await websocket_manager.send(wallet.inkey, payment.json())
+26 -72
View File
@@ -25,7 +25,6 @@ from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
from lnbits.wallets.base import ( from lnbits.wallets.base import (
InvoiceResponse, InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus, PaymentPendingStatus,
PaymentResponse, PaymentResponse,
PaymentStatus, PaymentStatus,
@@ -48,8 +47,7 @@ from ..models import (
PaymentState, PaymentState,
Wallet, Wallet,
) )
from .fiat_providers import check_fiat_status from .notifications import send_payment_notification
from .notifications import send_payment_notification_in_background
payment_lock = asyncio.Lock() payment_lock = asyncio.Lock()
wallets_payments_lock: dict[str, asyncio.Lock] = {} wallets_payments_lock: dict[str, asyncio.Lock] = {}
@@ -69,14 +67,12 @@ async def pay_invoice(
if settings.lnbits_only_allow_incoming_payments: if settings.lnbits_only_allow_incoming_payments:
raise PaymentError("Only incoming payments allowed.", status="failed") raise PaymentError("Only incoming payments allowed.", status="failed")
invoice = _validate_payment_request(payment_request, max_sat) invoice = _validate_payment_request(payment_request, max_sat)
if not invoice.amount_msat: if not invoice.amount_msat:
raise ValueError("Missig invoice amount.") raise ValueError("Missig invoice amount.")
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
amount_msat = invoice.amount_msat amount_msat = invoice.amount_msat
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn) wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
if not wallet.can_send_payments: if not wallet.can_send_payments:
raise PaymentError( raise PaymentError(
"Wallet does not have permission to pay invoices.", "Wallet does not have permission to pay invoices.",
@@ -99,12 +95,10 @@ async def pay_invoice(
labels=labels, labels=labels,
) )
async with db.reuse_conn(conn) if conn else db.connect() as new_conn: payment = await _pay_invoice(wallet.source_wallet_id, create_payment_model, conn)
payment = await _pay_invoice(
wallet.source_wallet_id, create_payment_model, conn=new_conn
)
await _credit_service_fee_wallet(wallet, payment, conn=new_conn) async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
await _credit_service_fee_wallet(wallet, payment, new_conn)
return payment return payment
@@ -203,22 +197,20 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
# do not save memo if description_hash or unhashed_description is set # do not save memo if description_hash or unhashed_description is set
memo = "" memo = ""
async with db.connect() as conn: payment = await create_invoice(
payment = await create_invoice( wallet_id=wallet_id,
wallet_id=wallet_id, amount=data.amount,
amount=data.amount, memo=memo,
memo=memo, currency=data.unit,
currency=data.unit, description_hash=description_hash,
description_hash=description_hash, unhashed_description=unhashed_description,
unhashed_description=unhashed_description, expiry=data.expiry,
expiry=data.expiry, extra=data.extra,
extra=data.extra, webhook=data.webhook,
webhook=data.webhook, internal=data.internal,
internal=data.internal, payment_hash=data.payment_hash,
payment_hash=data.payment_hash, labels=data.labels,
labels=data.labels, )
conn=conn,
)
if data.lnurl_withdraw: if data.lnurl_withdraw:
try: try:
@@ -363,15 +355,13 @@ async def update_pending_payments(wallet_id: str):
await update_pending_payment(payment) await update_pending_payment(payment)
async def update_pending_payment( async def update_pending_payment(payment: Payment) -> Payment:
payment: Payment, conn: Connection | None = None status = await payment.check_status()
) -> Payment:
status = await check_payment_status(payment)
if status.failed: if status.failed:
payment.status = PaymentState.FAILED payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn) await update_payment(payment)
elif status.success: elif status.success:
payment = await update_payment_success_status(payment, status, conn=conn) payment = await update_payment_success_status(payment, status)
return payment return payment
@@ -620,29 +610,7 @@ async def check_transaction_status(
if payment.status == PaymentState.SUCCESS.value: if payment.status == PaymentState.SUCCESS.value:
return PaymentSuccessStatus(fee_msat=payment.fee) return PaymentSuccessStatus(fee_msat=payment.fee)
return await check_payment_status(payment) return await payment.check_status()
async def check_payment_status(
payment: Payment, skip_internal_payment_notifications: bool | None = False
) -> PaymentStatus:
if payment.is_internal:
if payment.success:
return PaymentSuccessStatus()
if payment.failed:
return PaymentFailedStatus()
if payment.is_in and payment.fiat_provider:
fiat_status = await check_fiat_status(
payment, skip_internal_payment_notifications
)
return PaymentStatus(paid=fiat_status.paid)
return PaymentPendingStatus()
funding_source = get_funding_source()
if payment.is_out:
status = await funding_source.get_payment_status(payment.checking_id)
else:
status = await funding_source.get_invoice_status(payment.checking_id)
return status
async def get_payments_daily_stats( async def get_payments_daily_stats(
@@ -730,7 +698,6 @@ async def _pay_internal_invoice(
internal_payment = await check_internal( internal_payment = await check_internal(
create_payment_model.payment_hash, conn=conn create_payment_model.payment_hash, conn=conn
) )
if not internal_payment: if not internal_payment:
return None return None
@@ -739,7 +706,6 @@ async def _pay_internal_invoice(
internal_invoice = await get_standalone_payment( internal_invoice = await get_standalone_payment(
internal_payment.checking_id, incoming=True, conn=conn internal_payment.checking_id, incoming=True, conn=conn
) )
if not internal_invoice: if not internal_invoice:
raise PaymentError("Internal payment not found.", status="failed") raise PaymentError("Internal payment not found.", status="failed")
@@ -761,7 +727,6 @@ async def _pay_internal_invoice(
internal_id = f"internal_{create_payment_model.payment_hash}" internal_id = f"internal_{create_payment_model.payment_hash}"
logger.debug(f"creating temporary internal payment with id {internal_id}") logger.debug(f"creating temporary internal payment with id {internal_id}")
payment = await create_payment( payment = await create_payment(
checking_id=internal_id, checking_id=internal_id,
data=create_payment_model, data=create_payment_model,
@@ -776,7 +741,7 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn) await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}") logger.success(f"internal payment successful {internal_payment.checking_id}")
await _send_payment_notification_in_background(wallet.id, payment, conn=conn) await send_payment_notification(wallet, payment)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
@@ -849,8 +814,7 @@ async def _pay_external_invoice(
payment = await update_payment_success_status( payment = await update_payment_success_status(
payment, payment_response, conn=conn payment, payment_response, conn=conn
) )
await send_payment_notification(wallet, payment)
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
logger.success(f"payment successful {payment_response.checking_id}") logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id payment.checking_id = payment_response.checking_id
@@ -893,7 +857,7 @@ async def _verify_external_payment(
raise PaymentError("Payment already paid.", status="success") raise PaymentError("Payment already paid.", status="success")
# payment failed # payment failed
status = await check_payment_status(payment) status = await payment.check_status()
if status.failed: if status.failed:
raise PaymentError( raise PaymentError(
"Payment is failed node, retrying is not possible.", status="failed" "Payment is failed node, retrying is not possible.", status="failed"
@@ -1058,13 +1022,3 @@ async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
await update_payment(payment) await update_payment(payment)
return response return response
async def _send_payment_notification_in_background(
wallet_id: str, payment: Payment, conn: Connection | None = None
):
# fetch balance again
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment)
+24 -56
View File
@@ -3,11 +3,7 @@ from uuid import uuid4
from loguru import logger from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.db import db
from lnbits.core.models.extensions import UserExtension from lnbits.core.models.extensions import UserExtension
from lnbits.core.models.users import RegisterUser
from lnbits.db import Connection
from lnbits.settings import ( from lnbits.settings import (
EditableSettings, EditableSettings,
SuperSettings, SuperSettings,
@@ -52,43 +48,37 @@ async def create_user_account_no_ckeck(
account: Account | None = None, account: Account | None = None,
wallet_name: str | None = None, wallet_name: str | None = None,
default_exts: list[str] | None = None, default_exts: list[str] | None = None,
conn: Connection | None = None,
) -> User: ) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
if account:
account.validate_fields()
if account.username and await get_account_by_username(
account.username, conn=conn
):
raise ValueError("Username already exists.")
if account.email and await get_account_by_email(account.email, conn=conn): if account:
raise ValueError("Email already exists.") account.validate_fields()
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if account.pubkey and await get_account_by_pubkey( if account.email and await get_account_by_email(account.email):
account.pubkey, conn=conn raise ValueError("Email already exists.")
):
raise ValueError("Pubkey already exists.")
if not account.id: if account.pubkey and await get_account_by_pubkey(account.pubkey):
account.id = uuid4().hex raise ValueError("Pubkey already exists.")
account = await create_account(account, conn=conn) if not account.id:
await create_wallet( account.id = uuid4().hex
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
conn=conn,
)
user_extensions = (default_exts or []) + settings.lnbits_user_default_extensions account = await create_account(account)
for ext_id in user_extensions: await create_wallet(
try: user_id=account.id,
user_ext = UserExtension(user=account.id, extension=ext_id, active=True) wallet_name=wallet_name or settings.lnbits_default_wallet_name,
await create_user_extension(user_ext, conn=conn) )
except Exception as e:
logger.error(f"Error enabeling default extension {ext_id}: {e}")
user = await get_user_from_account(account, conn=conn) 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)
if not user: if not user:
raise ValueError("Cannot find user for account.") raise ValueError("Cannot find user for account.")
@@ -194,25 +184,3 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
editable_settings = EditableSettings.from_dict(settings.dict()) editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict()) return await create_admin_settings(account.id, editable_settings.dict())
async def check_register_activation_settings(data: RegisterUser):
if not settings.lnbits_require_user_activation:
return None
if settings.lnbits_user_activation_by_invitation_code:
code = data.invitation_code.strip() if data.invitation_code else ""
if len(code) == 0:
raise ValueError("Invitation code cannot be empty.")
if code == settings.lnbits_register_reusable_activation_code:
return None
if code in settings.lnbits_register_one_time_activation_codes:
settings.lnbits_register_one_time_activation_codes.remove(code)
await set_settings_field(
"lnbits_register_one_time_activation_codes",
settings.lnbits_register_one_time_activation_codes,
)
return None
raise ValueError("Invalid invitation code.")
raise ValueError("No activation method provided.")
+1 -3
View File
@@ -57,9 +57,7 @@ async def run_by_the_minute_tasks() -> None:
if minute_counter % 60 == 0: if minute_counter % 60 == 0:
try: try:
# initialize the list of all extensions # initialize the list of all extensions
await InstallableExtension.get_installable_extensions( await InstallableExtension.get_installable_extensions()
post_refresh_cache=True
)
except Exception as ex: except Exception as ex:
logger.error(ex) logger.error(ex)
+10 -14
View File
@@ -8,8 +8,8 @@ from urllib.parse import urlparse
from fastapi import APIRouter, Depends, File from fastapi import APIRouter, Depends, File
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from lnbits.core.models import User
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import Account
from lnbits.core.services import ( from lnbits.core.services import (
enqueue_admin_notification, enqueue_admin_notification,
get_balance_delta, get_balance_delta,
@@ -67,9 +67,9 @@ async def api_test_email():
@admin_router.get("/api/v1/settings") @admin_router.get("/api/v1/settings")
async def api_get_settings( async def api_get_settings(
account: Account = Depends(check_admin), user: User = Depends(check_admin),
) -> AdminSettings | None: ) -> AdminSettings | None:
admin_settings = await get_admin_settings(account.is_super_user) admin_settings = await get_admin_settings(user.super_user)
return admin_settings return admin_settings
@@ -77,14 +77,12 @@ async def api_get_settings(
"/api/v1/settings", "/api/v1/settings",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
) )
async def api_update_settings( async def api_update_settings(data: UpdateSettings, user: User = Depends(check_admin)):
data: UpdateSettings, account: Account = Depends(check_admin)
):
enqueue_admin_notification( enqueue_admin_notification(
NotificationType.settings_update, {"username": account.username} NotificationType.settings_update, {"username": user.username}
) )
await update_admin_settings(data) await update_admin_settings(data)
admin_settings = await get_admin_settings(account.is_super_user) admin_settings = await get_admin_settings(user.super_user)
if not admin_settings: if not admin_settings:
raise ValueError("Updated admin settings not found.") raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.dict()) update_cached_settings(admin_settings.dict())
@@ -96,11 +94,9 @@ async def api_update_settings(
"/api/v1/settings", "/api/v1/settings",
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
) )
async def api_update_settings_partial( async def api_update_settings_partial(data: dict, user: User = Depends(check_admin)):
data: dict, account: Account = Depends(check_admin)
):
updatable_settings = dict_to_settings({**settings.dict(), **data}) updatable_settings = dict_to_settings({**settings.dict(), **data})
return await api_update_settings(updatable_settings, account) return await api_update_settings(updatable_settings, user)
@admin_router.get( @admin_router.get(
@@ -114,9 +110,9 @@ async def api_reset_settings(field_name: str):
@admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK) @admin_router.delete("/api/v1/settings", status_code=HTTPStatus.OK)
async def api_delete_settings(account: Account = Depends(check_super_user)) -> None: async def api_delete_settings(user: User = Depends(check_super_user)) -> None:
enqueue_admin_notification( enqueue_admin_notification(
NotificationType.settings_update, {"username": account.username} NotificationType.settings_update, {"username": user.username}
) )
await reset_core_settings() await reset_core_settings()
server_restart.set() server_restart.set()
+6 -11
View File
@@ -8,17 +8,13 @@ from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from lnbits.core.models import ( from lnbits.core.models import (
BaseWallet,
ConversionData, ConversionData,
CreateWallet, CreateWallet,
User, User,
Wallet, Wallet,
) )
from lnbits.core.models.users import AccountId from lnbits.decorators import check_user_exists
from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
check_user_exists,
)
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
allowed_currencies, allowed_currencies,
@@ -43,9 +39,7 @@ async def health() -> dict:
@api_router.get("/api/v1/status", status_code=HTTPStatus.OK) @api_router.get("/api/v1/status", status_code=HTTPStatus.OK)
async def health_check( async def health_check(user: User = Depends(check_user_exists)) -> dict:
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
stat: dict[str, Any] = { stat: dict[str, Any] = {
"server_time": int(time()), "server_time": int(time()),
"up_time": settings.lnbits_server_up_time, "up_time": settings.lnbits_server_up_time,
@@ -53,7 +47,7 @@ async def health_check(
} }
stat["version"] = settings.version stat["version"] = settings.version
if not account_id.is_admin_id: if not user.admin:
return stat return stat
funding_source = get_funding_source() funding_source = get_funding_source()
@@ -70,6 +64,7 @@ async def health_check(
"/api/v1/wallets", "/api/v1/wallets",
name="Wallets", name="Wallets",
description="Get basic info for all of user's wallets.", description="Get basic info for all of user's wallets.",
response_model=list[BaseWallet],
) )
async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]: async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
return user.wallets return user.wallets
@@ -83,7 +78,7 @@ async def api_create_account(data: CreateWallet) -> Wallet:
@api_router.get( @api_router.get(
"/api/v1/rate/history", "/api/v1/rate/history",
dependencies=[Depends(check_account_exists)], dependencies=[Depends(check_user_exists)],
) )
async def api_exchange_rate_history() -> list[dict]: async def api_exchange_rate_history() -> list[dict]:
return settings.lnbits_exchange_rate_history return settings.lnbits_exchange_rate_history
+19 -19
View File
@@ -15,11 +15,11 @@ from lnbits.core.crud.assets import (
) )
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import AccountId from lnbits.core.models.users import User
from lnbits.core.services.assets import create_user_asset from lnbits.core.services.assets import create_user_asset
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_id_exists, check_user_exists,
optional_user_id, optional_user_id,
parse_filters, parse_filters,
) )
@@ -35,10 +35,10 @@ upload_file_param = File(...)
summary="Get paginated list user assets", summary="Get paginated list user assets",
) )
async def api_get_user_assets( async def api_get_user_assets(
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
filters: Filters = Depends(parse_filters(AssetFilters)), filters: Filters = Depends(parse_filters(AssetFilters)),
) -> Page[AssetInfo]: ) -> Page[AssetInfo]:
return await get_user_assets(account_id.id, filters=filters) return await get_user_assets(user.id, filters=filters)
@asset_router.get( @asset_router.get(
@@ -48,20 +48,20 @@ async def api_get_user_assets(
) )
async def api_get_asset( async def api_get_asset(
asset_id: str, asset_id: str,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> AssetInfo: ) -> AssetInfo:
asset_info = await get_user_asset_info(account_id.id, asset_id) asset_info = await get_user_asset_info(user.id, asset_id)
if not asset_info: if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_info return asset_info
@asset_router.get( @asset_router.get(
"/{asset_id}/data", "/{asset_id}/binary",
name="Get user asset data", name="Get user asset binary",
summary="Get user asset data data by ID", summary="Get user asset binary data by ID",
) )
async def api_get_asset_data( async def api_get_asset_binary(
asset_id: str, asset_id: str,
user_id: str | None = Depends(optional_user_id), user_id: str | None = Depends(optional_user_id),
) -> Response: ) -> Response:
@@ -120,12 +120,12 @@ async def api_get_asset_thumbnail(
async def api_update_asset( async def api_update_asset(
asset_id: str, asset_id: str,
data: AssetUpdate, data: AssetUpdate,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> AssetInfo: ) -> AssetInfo:
if account_id.is_admin_id: if user.admin:
asset_info = await get_asset_info(asset_id) asset_info = await get_asset_info(asset_id)
else: else:
asset_info = await get_user_asset_info(account_id.id, asset_id) asset_info = await get_user_asset_info(user.id, asset_id)
if not asset_info: if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
@@ -144,13 +144,13 @@ async def api_update_asset(
summary="Upload user assets", summary="Upload user assets",
) )
async def api_upload_asset( async def api_upload_asset(
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
file: UploadFile = upload_file_param, file: UploadFile = upload_file_param,
public_asset: bool = False, public_asset: bool = False,
) -> AssetInfo: ) -> AssetInfo:
asset = await create_user_asset(account_id.id, file, public_asset) asset = await create_user_asset(user.id, file, public_asset)
asset_info = await get_user_asset_info(account_id.id, asset.id) asset_info = await get_user_asset_info(user.id, asset.id)
if not asset_info: if not asset_info:
raise ValueError("Failed to retrieve asset info after upload.") raise ValueError("Failed to retrieve asset info after upload.")
@@ -164,11 +164,11 @@ async def api_upload_asset(
) )
async def api_delete_asset( async def api_delete_asset(
asset_id: str, asset_id: str,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> SimpleStatus: ) -> SimpleStatus:
asset = await get_user_asset(account_id.id, asset_id) asset = await get_user_asset(user.id, asset_id)
if not asset: if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
await delete_user_asset(account_id.id, asset_id) await delete_user_asset(user.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.") return SimpleStatus(success=True, message="Asset deleted successfully.")
+45 -121
View File
@@ -4,10 +4,9 @@ import json
from collections.abc import Callable from collections.abc import Callable
from http import HTTPStatus from http import HTTPStatus
from time import time from time import time
from typing import Annotated
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger from loguru import logger
@@ -26,16 +25,8 @@ from lnbits.core.models.users import (
UpdateAccessControlList, UpdateAccessControlList,
) )
from lnbits.core.services import create_user_account from lnbits.core.services import create_user_account
from lnbits.core.services.users import ( from lnbits.core.services.users import update_user_account
check_register_activation_settings, from lnbits.decorators import access_token_payload, check_user_exists
update_user_account,
)
from lnbits.decorators import (
access_token_payload,
check_account_exists,
check_admin,
check_user_exists,
)
from lnbits.helpers import ( from lnbits.helpers import (
create_access_token, create_access_token,
decrypt_internal_message, decrypt_internal_message,
@@ -89,7 +80,6 @@ async def login(data: LoginUsernamePassword) -> JSONResponse:
account = await get_account_by_username_or_email(data.username) account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(account.username, account.id, account.email) return _auth_success_response(account.username, account.id, account.email)
@@ -98,7 +88,7 @@ async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98): if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.") raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
event = _nostr_nip98_event(request) event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"], active_only=False) account = await get_account_by_pubkey(event["pubkey"])
if not account: if not account:
account = Account( account = Account(
id=uuid4().hex, id=uuid4().hex,
@@ -106,8 +96,6 @@ async def nostr_login(request: Request) -> JSONResponse:
extra=UserExtra(provider="nostr"), extra=UserExtra(provider="nostr"),
) )
await create_user_account(account) await create_user_account(account)
if not account.activated:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User is not activated.")
return _auth_success_response(account.username or "", account.id, account.email) return _auth_success_response(account.username or "", account.id, account.email)
@@ -128,71 +116,14 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
return _auth_success_response(account.username, account.id, account.email) return _auth_success_response(account.username, account.id, account.email)
@auth_router.post("/impersonate", description="Login via the User ID of another user")
async def impersonate_user(
data: LoginUsr,
user: User = Depends(check_admin),
cookie_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not cookie_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Only cookie based impersonation is allowed."
)
if data.usr == user.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "You cannot impersonate yourself.")
if settings.is_admin_user(data.usr):
# this check includes the superuser
raise HTTPException(
HTTPStatus.FORBIDDEN, "You cannot impersonate another admin user."
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
response = _auth_success_response(account.username, account.id, account.email)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
return response
@auth_router.delete(
"/impersonate", description="Stop impersonation and go back to admin"
)
async def stop_impersonate_user(
user: User = Depends(check_user_exists),
admin_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not admin_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"No admin access token found to stop impersonation.",
)
response = JSONResponse(
{"access_token": admin_access_token, "token_type": "bearer"}
)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
)
response.delete_cookie("admin_access_token")
response.delete_cookie("is_access_token_expired")
response.delete_cookie("is_lnbits_user_impersonated")
return response
@auth_router.get("/acl") @auth_router.get("/acl")
async def api_get_user_acls( async def api_get_user_acls(
request: Request, request: Request,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
) -> UserAcls: ) -> UserAcls:
api_routes = get_api_routes(request.app.router.routes) api_routes = get_api_routes(request.app.router.routes)
acls = await get_user_access_control_lists(account.id) acls = await get_user_access_control_lists(user.id)
# Add missing/new endpoints to the ACLs # Add missing/new endpoints to the ACLs
for acl in acls.access_control_list: for acl in acls.access_control_list:
@@ -205,7 +136,7 @@ async def api_get_user_acls(
acl.endpoints.append(EndpointAccess(path=path, name=name)) acl.endpoints.append(EndpointAccess(path=path, name=name))
acl.endpoints.sort(key=lambda e: e.name.lower()) acl.endpoints.sort(key=lambda e: e.name.lower())
return UserAcls(id=account.id, access_control_list=acls.access_control_list) return UserAcls(id=user.id, access_control_list=acls.access_control_list)
@auth_router.put("/acl") @auth_router.put("/acl")
@@ -213,13 +144,13 @@ async def api_get_user_acls(
async def api_update_user_acl( async def api_update_user_acl(
request: Request, request: Request,
data: UpdateAccessControlList, data: UpdateAccessControlList,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
) -> UserAcls: ) -> UserAcls:
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
user_acls = await get_user_access_control_lists(account.id) user_acls = await get_user_access_control_lists(user.id)
acl = user_acls.get_acl_by_id(data.id) acl = user_acls.get_acl_by_id(data.id)
if acl: if acl:
user_acls.access_control_list.remove(acl) user_acls.access_control_list.remove(acl)
@@ -244,30 +175,33 @@ async def api_update_user_acl(
@auth_router.delete("/acl") @auth_router.delete("/acl")
async def api_delete_user_acl( async def api_delete_user_acl(
data: DeleteAccessControlList, account: Account = Depends(check_account_exists) data: DeleteAccessControlList,
user: User = Depends(check_user_exists),
): ):
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
user_acls = await get_user_access_control_lists(account.id) user_acls = await get_user_access_control_lists(user.id)
user_acls.delete_acl_by_id(data.id) user_acls.delete_acl_by_id(data.id)
await update_user_access_control_list(user_acls) await update_user_access_control_list(user_acls)
@auth_router.post("/acl/token") @auth_router.post("/acl/token")
async def api_create_user_api_token( async def api_create_user_api_token(
data: ApiTokenRequest, account: Account = Depends(check_account_exists) data: ApiTokenRequest,
user: User = Depends(check_user_exists),
) -> ApiTokenResponse: ) -> ApiTokenResponse:
if not data.expiration_time_minutes > 0: if not data.expiration_time_minutes > 0:
raise ValueError("Expiration time must be in the future.") raise ValueError("Expiration time must be in the future.")
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
if not account.username: if not account.username:
raise ValueError("Username must be configured.") raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(account.id) acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id) acl = acls.get_acl_by_id(data.acl_id)
if not acl: if not acl:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
@@ -284,16 +218,18 @@ async def api_create_user_api_token(
@auth_router.delete("/acl/token") @auth_router.delete("/acl/token")
async def api_delete_user_api_token( async def api_delete_user_api_token(
data: DeleteTokenRequest, account: Account = Depends(check_account_exists) data: DeleteTokenRequest,
user: User = Depends(check_user_exists),
): ):
account = await get_account(user.id)
if not account or not account.verify_password(data.password): if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
if not account.username: if not account.username:
raise ValueError("Username must be configured.") raise ValueError("Username must be configured.")
acls = await get_user_access_control_lists(account.id) acls = await get_user_access_control_lists(user.id)
acl = acls.get_acl_by_id(data.acl_id) acl = acls.get_acl_by_id(data.acl_id)
if not acl: if not acl:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid ACL id.")
@@ -363,14 +299,12 @@ async def register(data: RegisterUser) -> JSONResponse:
if not is_valid_username(data.username): if not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if await get_account_by_username(data.username, active_only=False): if await get_account_by_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email): if data.email and not is_valid_email_address(data.email):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
await check_register_activation_settings(data)
account = Account( account = Account(
id=uuid4().hex, id=uuid4().hex,
email=data.email, email=data.email,
@@ -384,20 +318,24 @@ async def register(data: RegisterUser) -> JSONResponse:
@auth_router.put("/pubkey") @auth_router.put("/pubkey")
async def update_pubkey( async def update_pubkey(
data: UpdateUserPubkey, data: UpdateUserPubkey,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload), payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None: ) -> User | None:
if data.user_id != account.id: if data.user_id != user.id:
raise ValueError("Invalid user ID.") raise ValueError("Invalid user ID.")
_validate_auth_timeout(payload.auth_time) _validate_auth_timeout(payload.auth_time)
if ( if (
data.pubkey data.pubkey
and data.pubkey != account.pubkey and data.pubkey != user.pubkey
and await get_account_by_pubkey(data.pubkey) and await get_account_by_pubkey(data.pubkey)
): ):
raise ValueError("Public key already in use.") raise ValueError("Public key already in use.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
account.pubkey = normalize_public_key(data.pubkey) account.pubkey = normalize_public_key(data.pubkey)
await update_account(account) await update_account(account)
return await get_user_from_account(account) return await get_user_from_account(account)
@@ -406,19 +344,23 @@ async def update_pubkey(
@auth_router.put("/password") @auth_router.put("/password")
async def update_password( async def update_password(
data: UpdateUserPassword, data: UpdateUserPassword,
account: Account = Depends(check_account_exists), user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload), payload: AccessTokenPayload = Depends(access_token_payload),
) -> User | None: ) -> User | None:
_validate_auth_timeout(payload.auth_time) _validate_auth_timeout(payload.auth_time)
if data.user_id != account.id: if data.user_id != user.id:
raise ValueError("Invalid user ID.") raise ValueError("Invalid user ID.")
if ( if (
data.username data.username
and account.username != data.username and user.username != data.username
and await get_account_by_username(data.username) and await get_account_by_username(data.username)
): ):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
if not account:
raise ValueError("Account not found.")
# old accounts do not have a password # old accounts do not have a password
if account.password_hash: if account.password_hash:
if not data.password_old: if not data.password_old:
@@ -475,13 +417,17 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
return _auth_success_response(account.username, user_id, account.email) return _auth_success_response(account.username, user_id, account.email)
@auth_router.patch("") @auth_router.put("/update")
async def update( async def update(
data: UpdateUser, account: Account = Depends(check_account_exists) data: UpdateUser, user: User = Depends(check_user_exists)
) -> User | None: ) -> User | None:
if data.user_id != account.id: if data.user_id != user.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
if data.username: if data.username:
account.username = data.username account.username = data.username
if data.extra: if data.extra:
@@ -491,32 +437,10 @@ async def update(
return await get_user_from_account(account) return await get_user_from_account(account)
@auth_router.patch("/ui")
async def update_ui_customization(
req: Request, account: Account = Depends(check_account_exists)
) -> Account:
ui_customization = await req.json()
account.ui_customization = {**(account.ui_customization or {}), **ui_customization}
if len(account.ui_customization or {}) > 1000 * 1024:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "UI customization too large. Drop some fields."
)
await update_user_account(account)
return account
@auth_router.put("/first_install") @auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse: async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install: if not settings.first_install:
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install") raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
if settings.first_install_token:
if not data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing first_install_token.")
if settings.first_install_token != data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid first_install_token.")
account = await get_account(settings.super_user) account = await get_account(settings.super_user)
if not account: if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.") raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
@@ -535,7 +459,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet" redirect_path = "/wallet"
account = await get_account_by_email(email, active_only=False) account = await get_account_by_email(email)
if verified_user_id: if verified_user_id:
if account: if account:
+6 -122
View File
@@ -9,9 +9,7 @@ from lnbits.core.crud.payments import (
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import ( from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_stripe_signature, check_stripe_signature,
verify_paypal_webhook,
) )
from lnbits.core.services.payments import create_fiat_invoice from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions from lnbits.fiat.base import FiatSubscriptionPaymentOptions
@@ -39,17 +37,6 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.", message=f"Callback received successfully from '{provider_name}'.",
) )
if provider_name.lower() == "paypal":
payload = await request.body()
await verify_paypal_webhook(request.headers, payload)
event = await request.json()
await handle_paypal_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus( return SimpleStatus(
success=False, success=False,
message=f"Unknown fiat provider '{provider_name}'.", message=f"Unknown fiat provider '{provider_name}'.",
@@ -61,8 +48,6 @@ async def handle_stripe_event(event: dict):
event_type = event.get("type") event_type = event.get("type")
if event_type == "checkout.session.completed": if event_type == "checkout.session.completed":
await _handle_stripe_checkout_session_completed(event) await _handle_stripe_checkout_session_completed(event)
elif event_type == "payment_intent.succeeded":
await _handle_stripe_intent_session_completed(event)
elif event_type == "invoice.paid": elif event_type == "invoice.paid":
await _handle_stripe_subscription_invoice_paid(event) await _handle_stripe_subscription_invoice_paid(event)
else: else:
@@ -71,38 +56,18 @@ async def handle_stripe_event(event: dict):
) )
async def _handle_stripe_intent_session_completed(event: dict):
event_id = event.get("id")
event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash")
logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'."
)
if not payment_hash:
logger.warning("Stripe event does not contain a payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await check_fiat_status(payment)
async def _handle_stripe_checkout_session_completed(event: dict): async def _handle_stripe_checkout_session_completed(event: dict):
event_id = event.get("id") event_id = event.get("id")
event_object = event.get("data", {}).get("object", {}) event_object = event.get("data", {}).get("object", {})
object_type = event_object.get("object") object_type = event_object.get("object")
payment_hash = event_object.get("metadata", {}).get("payment_hash") payment_hash = event_object.get("metadata", {}).get("payment_hash")
alan_action = event_object.get("metadata", {}).get("alan_action") lnbits_action = event_object.get("metadata", {}).get("lnbits_action")
logger.debug( logger.debug(
f"Handling Stripe event: '{event_id}'. Type: '{object_type}'." f"Handling Stripe event: '{event_id}'. Type: '{object_type}'."
f" Payment hash: '{payment_hash}'." f" Payment hash: '{payment_hash}'."
) )
if alan_action != "invoice": if lnbits_action != "invoice":
logger.warning(f"Stripe event is not an invoice: '{alan_action}'.") logger.warning(f"Stripe event is not an invoice: '{lnbits_action}'.")
return return
if not payment_hash: if not payment_hash:
@@ -111,7 +76,7 @@ async def _handle_stripe_checkout_session_completed(event: dict):
payment = await get_standalone_payment(payment_hash) payment = await get_standalone_payment(payment_hash)
if not payment: if not payment:
raise ValueError(f"No payment found for hash: '{payment_hash}'.") raise ValueError(f"No payment found for hash: '{payment_hash}'.")
await check_fiat_status(payment) await payment.check_fiat_status()
async def _handle_stripe_subscription_invoice_paid(event: dict): async def _handle_stripe_subscription_invoice_paid(event: dict):
@@ -156,7 +121,7 @@ async def _handle_stripe_subscription_invoice_paid(event: dict):
), ),
) )
await check_fiat_status(payment) await payment.check_fiat_status()
async def _get_stripe_subscription_payment_options( async def _get_stripe_subscription_payment_options(
@@ -167,7 +132,7 @@ async def _get_stripe_subscription_payment_options(
metadata = parent.get("subscription_details", {}).get("metadata", {}) metadata = parent.get("subscription_details", {}).get("metadata", {})
if metadata.get("alan_action") != "subscription": if metadata.get("lnbits_action") != "subscription":
raise ValueError("Stripe invoice.paid metadata action is not 'subscription'.") raise ValueError("Stripe invoice.paid metadata action is not 'subscription'.")
if "extra" in metadata: if "extra" in metadata:
@@ -178,84 +143,3 @@ async def _get_stripe_subscription_payment_options(
metadata["extra"] = {} metadata["extra"] = {}
return FiatSubscriptionPaymentOptions(**metadata) return FiatSubscriptionPaymentOptions(**metadata)
async def handle_paypal_event(event: dict):
event_type = event.get("event_type", "")
resource = event.get("resource", {})
if event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED"):
payment_hash = _paypal_extract_payment_hash(resource)
if not payment_hash:
logger.warning("PayPal event missing payment hash.")
return
payment = await get_standalone_payment(payment_hash)
if not payment:
logger.warning(f"No payment found for hash: '{payment_hash}'.")
return
await check_fiat_status(payment)
return
if event_type in (
"PAYMENT.SALE.COMPLETED",
"BILLING.SUBSCRIPTION.PAYMENT.SUCCEEDED",
):
await _handle_paypal_subscription_payment(resource)
return
logger.info(f"Unhandled PayPal event type: '{event_type}'.")
async def _handle_paypal_subscription_payment(resource: dict):
amount_info = resource.get("amount") or {}
currency = (amount_info.get("currency_code") or "").upper()
total = amount_info.get("value")
if not currency or total is None:
raise ValueError("PayPal subscription event missing amount.")
custom_id = resource.get("custom_id") or resource.get("custom")
if not custom_id:
raise ValueError("PayPal subscription event missing custom metadata.")
try:
metadata = json.loads(custom_id)
except json.JSONDecodeError:
metadata = {}
payment_options = FiatSubscriptionPaymentOptions(**metadata)
if not payment_options.wallet_id:
raise ValueError("PayPal subscription event missing wallet_id.")
memo = payment_options.memo or ""
extra = {
**(payment_options.extra or {}),
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": resource.get("id") or resource.get("billing_agreement_id"),
"payment_request": "",
},
}
payment = await create_fiat_invoice(
wallet_id=payment_options.wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=float(total),
memo=memo,
extra=extra,
fiat_provider="paypal",
),
)
await check_fiat_status(payment)
def _paypal_extract_payment_hash(resource: dict) -> str | None:
purchase_units = resource.get("purchase_units") or []
for pu in purchase_units:
if pu.get("invoice_id"):
return pu.get("invoice_id")
if pu.get("custom_id"):
return pu.get("custom_id")
return None
+30 -99
View File
@@ -2,35 +2,27 @@ import sys
import traceback import traceback
from http import HTTPStatus from http import HTTPStatus
import httpx
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.requests import Request
from loguru import logger from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids
from lnbits.core.db import db
from lnbits.core.models import ( from lnbits.core.models import (
SimpleStatus, SimpleStatus,
User,
) )
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
CreateExtension, CreateExtension,
CreateExtensionReview,
Extension, Extension,
ExtensionConfig, ExtensionConfig,
ExtensionMeta, ExtensionMeta,
ExtensionRelease, ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
InstallableExtension, InstallableExtension,
PayToEnableInfo, PayToEnableInfo,
ReleasePaymentInfo, ReleasePaymentInfo,
UserExtension, UserExtension,
UserExtensionInfo, UserExtensionInfo,
) )
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services import check_transaction_status, create_invoice from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.core.services.extensions import ( from lnbits.core.services.extensions import (
activate_extension, activate_extension,
@@ -40,11 +32,9 @@ from lnbits.core.services.extensions import (
install_extension, install_extension,
uninstall_extension, uninstall_extension,
) )
from lnbits.db import Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_exists,
check_account_id_exists,
check_admin, check_admin,
check_user_exists,
) )
from lnbits.settings import settings from lnbits.settings import settings
@@ -152,10 +142,9 @@ async def api_extension_details(
async def api_update_pay_to_enable( async def api_update_pay_to_enable(
ext_id: str, ext_id: str,
data: PayToEnableInfo, data: PayToEnableInfo,
account: Account = Depends(check_admin), user: User = Depends(check_admin),
) -> SimpleStatus: ) -> SimpleStatus:
user_wallet_ids = await get_wallets_ids(account.id, deleted=False) if data.wallet not in user.wallet_ids:
if data.wallet not in user_wallet_ids:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user." HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
) )
@@ -174,7 +163,7 @@ async def api_update_pay_to_enable(
@extension_router.put("/{ext_id}/enable") @extension_router.put("/{ext_id}/enable")
async def api_enable_extension( async def api_enable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]: if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException( raise HTTPException(
@@ -188,12 +177,12 @@ async def api_enable_extension(
if not ext.active: if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.") raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(user.id, ext_id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
if account_id.is_admin_id or not ext.requires_payment: if user.admin or not ext.requires_payment:
user_ext.active = True user_ext.active = True
await update_user_extension(user_ext) await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
@@ -230,13 +219,13 @@ async def api_enable_extension(
@extension_router.put("/{ext_id}/disable") @extension_router.put("/{ext_id}/disable")
async def api_disable_extension( async def api_disable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists) ext_id: str, user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]: if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException( raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist." HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
) )
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(user.id, ext_id)
if not user_ext or not user_ext.active: if not user_ext or not user_ext.active:
return SimpleStatus( return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled." success=True, message=f"Extension '{ext_id}' already disabled."
@@ -387,9 +376,7 @@ async def get_pay_to_install_invoice(
@extension_router.put("/{ext_id}/invoice/enable") @extension_router.put("/{ext_id}/invoice/enable")
async def get_pay_to_enable_invoice( async def get_pay_to_enable_invoice(
ext_id: str, ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
data: PayToEnableInfo,
account_id: AccountId = Depends(check_account_id_exists),
): ):
if not data.amount or data.amount <= 0: if not data.amount or data.amount <= 0:
raise HTTPException( raise HTTPException(
@@ -435,9 +422,9 @@ async def get_pay_to_enable_invoice(
memo=f"Enable '{ext.name}' extension.", memo=f"Enable '{ext.name}' extension.",
) )
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(user.id, ext_id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo() user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
user_ext_info.payment_hash_to_enable = payment.payment_hash user_ext_info.payment_hash_to_enable = payment.payment_hash
@@ -448,7 +435,7 @@ async def get_pay_to_enable_invoice(
@extension_router.get( @extension_router.get(
"/release/{org}/{repo}/{tag_name}", "/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_account_exists)], dependencies=[Depends(check_user_exists)],
) )
async def get_extension_release(org: str, repo: str, tag_name: str): async def get_extension_release(org: str, repo: str, tag_name: str):
try: try:
@@ -469,18 +456,15 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
@extension_router.get("") @extension_router.get("")
async def api_get_user_extensions( async def api_get_user_extensions(
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> list[Extension]: ) -> list[Extension]:
async with db.connect() as conn:
user_extensions_ids = [ user_extensions_ids = [ue.extension for ue in await get_user_extensions(user.id)]
ue.extension for ue in await get_user_extensions(account_id.id, conn=conn) return [
] ext
valid_extensions = [ for ext in await get_valid_extensions(False)
ext if ext.code in user_extensions_ids
for ext in await get_valid_extensions(False, conn=conn) ]
if ext.code in user_extensions_ids
]
return valid_extensions
@extension_router.delete( @extension_router.delete(
@@ -514,22 +498,11 @@ async def delete_extension_db(ext_id: str):
# TODO: create a response model for this # TODO: create a response model for this
@extension_router.get("/all") @extension_router.get("/all")
async def extensions(account_id: AccountId = Depends(check_account_id_exists)): async def extensions(user: User = Depends(check_user_exists)):
async with db.connect() as conn: installed_exts: list[InstallableExtension] = await get_installed_extensions()
installed_exts: list[InstallableExtension] = await get_installed_extensions(
conn=conn
)
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False, conn=conn)
]
db_versions = await get_db_versions(conn=conn)
installed_exts_ids = [e.id for e in installed_exts] installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions( installable_exts = await InstallableExtension.get_installable_extensions()
post_refresh_cache=account_id.is_admin_id
)
installable_exts_ids = [e.id for e in installable_exts] installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids] installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
@@ -537,7 +510,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None) installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta: if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id: if installed_ext.meta.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id # not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable pay_to_enable = installed_ext.meta.pay_to_enable
@@ -555,6 +528,10 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
e.short_description = installed_ext.short_description e.short_description = installed_ext.short_description
e.icon = installed_ext.icon e.icon = installed_ext.icon
all_ext_ids = [ext.code for ext in await get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_versions = await get_db_versions()
extension_data = [ extension_data = [
{ {
"id": ext.id, "id": ext.id,
@@ -596,49 +573,3 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
for ext in installable_exts for ext in installable_exts
] ]
return extension_data return extension_data
@extension_router.get(
"/reviews/tags",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews_tags() -> list[ExtensionReviewsStatus]:
async with httpx.AsyncClient() as client:
resp = await client.get(settings.lnbits_extensions_reviews_url + "/tags")
resp.raise_for_status()
data = resp.json()
return [ExtensionReviewsStatus(**item) for item in data]
@extension_router.get(
"/reviews/{ext_id}",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews(ext_id: str, request: Request) -> Page[ExtensionReview]:
async with httpx.AsyncClient() as client:
query_string = str(request.query_params)
resp = await client.get(
settings.lnbits_extensions_reviews_url + f"/reviews/{ext_id}?{query_string}"
)
resp.raise_for_status()
reviews = resp.json()
return Page(
data=[ExtensionReview(**item) for item in reviews["data"]],
total=reviews["total"],
)
@extension_router.put(
"/reviews",
dependencies=[Depends(check_account_exists)],
)
async def create_extension_review(
data: CreateExtensionReview,
) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client:
resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
)
resp.raise_for_status()
payment_request = resp.json()
return ExtensionReviewPaymentRequest(**payment_request)
+8 -8
View File
@@ -7,6 +7,7 @@ from fastapi.responses import FileResponse
from lnbits.core.models import ( from lnbits.core.models import (
SimpleStatus, SimpleStatus,
User,
) )
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
Extension, Extension,
@@ -15,7 +16,6 @@ from lnbits.core.models.extensions import (
UserExtension, UserExtension,
) )
from lnbits.core.models.extensions_builder import ExtensionData from lnbits.core.models.extensions_builder import ExtensionData
from lnbits.core.models.users import Account, AccountId
from lnbits.core.services.extensions import ( from lnbits.core.services.extensions import (
activate_extension, activate_extension,
install_extension, install_extension,
@@ -26,9 +26,9 @@ from lnbits.core.services.extensions_builder import (
zip_directory, zip_directory,
) )
from lnbits.decorators import ( from lnbits.decorators import (
check_account_id_exists,
check_admin, check_admin,
check_extension_builder, check_extension_builder,
check_user_exists,
) )
from ..crud import ( from ..crud import (
@@ -84,9 +84,9 @@ async def api_build_extension(data: ExtensionData) -> FileResponse:
) )
async def api_deploy_extension( async def api_deploy_extension(
data: ExtensionData, data: ExtensionData,
account: Account = Depends(check_admin), user: User = Depends(check_admin),
) -> SimpleStatus: ) -> SimpleStatus:
working_dir_name = "deploy_" + sha256(account.id.encode("utf-8")).hexdigest() working_dir_name = "deploy_" + sha256(user.id.encode("utf-8")).hexdigest()
stub_ext_id = "extension_builder_stub" stub_ext_id = "extension_builder_stub"
release, build_dir = await build_extension_from_data( release, build_dir = await build_extension_from_data(
data, stub_ext_id, working_dir_name data, stub_ext_id, working_dir_name
@@ -110,9 +110,9 @@ async def api_deploy_extension(
await activate_extension(Extension.from_installable_ext(ext_info)) await activate_extension(Extension.from_installable_ext(ext_info))
user_ext = await get_user_extension(account.id, data.id) user_ext = await get_user_extension(user.id, data.id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account.id, extension=data.id, active=True) user_ext = UserExtension(user=user.id, extension=data.id, active=True)
await create_user_extension(user_ext) await create_user_extension(user_ext)
elif not user_ext.active: elif not user_ext.active:
user_ext.active = True user_ext.active = True
@@ -128,10 +128,10 @@ async def api_deploy_extension(
) )
async def api_preview_extension( async def api_preview_extension(
data: ExtensionData, data: ExtensionData,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> SimpleStatus: ) -> SimpleStatus:
stub_ext_id = "extension_builder_stub" stub_ext_id = "extension_builder_stub"
working_dir_name = "preview_" + sha256(account_id.id.encode("utf-8")).hexdigest() working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
await build_extension_from_data(data, stub_ext_id, working_dir_name) await build_extension_from_data(data, stub_ext_id, working_dir_name)
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.") return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")
+15 -23
View File
@@ -75,28 +75,20 @@ async def cancel_subscription(
) )
async def connection_token(provider: str): async def connection_token(provider: str):
fiat_provider = await get_fiat_provider(provider) fiat_provider = await get_fiat_provider(provider)
if not fiat_provider: if provider == "stripe":
raise HTTPException(status_code=404, detail="Fiat provider not found") if not isinstance(fiat_provider, StripeWallet):
if provider != "stripe":
raise HTTPException(
status_code=400,
detail=f"Connection tokens are not supported for provider '{provider}'.",
)
if not isinstance(fiat_provider, StripeWallet):
raise HTTPException(
status_code=500, detail="Stripe wallet/provider not configured"
)
try:
tok = await fiat_provider.create_terminal_connection_token()
secret = tok.get("secret")
if not secret:
raise HTTPException( raise HTTPException(
status_code=502, detail="Stripe returned no connection token" status_code=500, detail="Stripe wallet/provider not configured"
) )
return {"secret": secret} try:
except Exception as e: tok = await fiat_provider.create_terminal_connection_token()
raise HTTPException( secret = tok.get("secret")
status_code=500, detail="Failed to create connection token" if not secret:
) from e raise HTTPException(
status_code=502, detail="Stripe returned no connection token"
)
return {"secret": secret}
except Exception as e:
raise HTTPException(
status_code=500, detail="Failed to create connection token"
) from e
+36 -20
View File
@@ -42,44 +42,60 @@ async def robots():
@generic_router.get( @generic_router.get(
"/extensions/builder/preview/{ext_id}/{resource}", "/extensions/builder/preview/{ext_id}",
name="extensions builder", name="extensions builder",
dependencies=[Depends(check_extension_builder)], dependencies=[Depends(check_extension_builder)],
) )
async def extensions_builder_preview( async def extensions_builder_preview(
request: Request,
ext_id: str, ext_id: str,
resource: str | None = None,
page_name: str | None = None, page_name: str | None = None,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
) -> FileResponse: ) -> HTMLResponse:
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest() working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html"
file_name = "index"
if page_name == "public_page": if page_name == "public_page":
file_name = "public_page" html_file_name = "public_page.html"
resource_path = Path( html_file_path = Path(
settings.extension_builder_working_dir_path,
"extension_builder_stub", "extension_builder_stub",
ext_id, ext_id,
working_dir_name, working_dir_name,
ext_id, ext_id,
"static", "templates",
ext_id,
html_file_name,
) )
file_ext = ".vue" if resource == "template" else ".js" html_file_full_path = Path(
file_full_path = Path(resource_path, file_name + file_ext) settings.extension_builder_working_dir_path, html_file_path
)
if not file_full_path.is_file(): if not html_file_full_path.is_file():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND) return template_renderer().TemplateResponse(
request,
"error.html",
{
"err": f"Extension {ext_id} not found",
"message": "Please 'Refresh Preview' first.",
},
status_code=HTTPStatus.NOT_FOUND,
)
response = template_renderer().TemplateResponse(
request,
html_file_path.as_posix(),
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
response = FileResponse(file_full_path.absolute().as_posix())
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'" "script-src 'self' 'unsafe-inline' 'unsafe-eval'"
) )
return response return response
@@ -181,7 +197,6 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get("/payments") @generic_router.get("/payments")
@generic_router.get("/wallet") @generic_router.get("/wallet")
@generic_router.get("/wallet/{wallet_id}")
@generic_router.get("/wallets") @generic_router.get("/wallets")
@generic_router.get("/account") @generic_router.get("/account")
@generic_router.get("/extensions") @generic_router.get("/extensions")
@@ -192,9 +207,6 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get( @generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)] "/extensions/builder", dependencies=[Depends(check_extension_builder)]
) )
@generic_router.get(
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
)
async def index( async def index(
request: Request, user: User = Depends(check_user_exists) request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse: ) -> HTMLResponse:
@@ -211,7 +223,7 @@ async def index(
@generic_router.get("/node/public") @generic_router.get("/node/public")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)]) @generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse: async def index_public(request: Request) -> HTMLResponse:
return template_renderer().TemplateResponse(request, "index.html", {"public": True}) return template_renderer().TemplateResponse(request, "index_public.html")
@generic_router.get("/uuidv4/{hex_value}") @generic_router.get("/uuidv4/{hex_value}")
@@ -272,3 +284,7 @@ async def lnurlwallet(request: Request, lightning: str = ""):
return RedirectResponse( return RedirectResponse(
f"/wallet?usr={account.id}&wal={wallet.id}", f"/wallet?usr={account.id}&wal={wallet.id}",
) )
def _is_ajax_request(request: Request):
return request.headers.get("X-Requested-With", None) == "XMLHttpRequest"
+3 -3
View File
@@ -24,7 +24,7 @@ from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
require_admin_key, require_admin_key,
require_base_invoice_key, require_invoice_key,
) )
from lnbits.helpers import check_callback_url from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
@@ -48,7 +48,7 @@ async def _handle(lnurl: str) -> LnurlResponseModel:
@lnurl_router.get( @lnurl_router.get(
"/api/v1/lnurlscan/{code}", "/api/v1/lnurlscan/{code}",
dependencies=[Depends(require_base_invoice_key)], dependencies=[Depends(require_invoice_key)],
deprecated=True, deprecated=True,
response_model=LnurlPayResponse response_model=LnurlPayResponse
| LnurlWithdrawResponse | LnurlWithdrawResponse
@@ -64,7 +64,7 @@ async def api_lnurlscan(code: str) -> LnurlResponseModel:
@lnurl_router.post( @lnurl_router.post(
"/api/v1/lnurlscan", "/api/v1/lnurlscan",
dependencies=[Depends(require_base_invoice_key)], dependencies=[Depends(require_invoice_key)],
response_model=LnurlPayResponse response_model=LnurlPayResponse
| LnurlWithdrawResponse | LnurlWithdrawResponse
| LnurlAuthResponse | LnurlAuthResponse
+43 -48
View File
@@ -18,7 +18,6 @@ from lnbits.core.crud.payments import (
update_payment, update_payment,
) )
from lnbits.core.crud.users import get_account from lnbits.core.crud.users import get_account
from lnbits.core.db import db
from lnbits.core.models import ( from lnbits.core.models import (
CancelInvoice, CancelInvoice,
CreateInvoice, CreateInvoice,
@@ -36,16 +35,14 @@ from lnbits.core.models import (
SimpleStatus, SimpleStatus,
) )
from lnbits.core.models.payments import UpdatePaymentLabels from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import AccountId from lnbits.core.models.users import User
from lnbits.core.models.wallets import BaseWalletTypeInfo
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
check_account_id_exists, check_user_exists,
parse_filters, parse_filters,
require_admin_key, require_admin_key,
require_base_admin_key, require_invoice_key,
require_base_invoice_key,
) )
from lnbits.helpers import ( from lnbits.helpers import (
filter_dict_keys, filter_dict_keys,
@@ -70,6 +67,7 @@ from ..services import (
perform_withdraw, perform_withdraw,
settle_hold_invoice, settle_hold_invoice,
update_pending_payment, update_pending_payment,
update_pending_payments,
) )
payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"]) payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
@@ -84,9 +82,10 @@ payment_router = APIRouter(prefix="/api/v1/payments", tags=["Payments"])
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments( async def api_payments(
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(key_info.wallet.id)
return await get_payments( return await get_payments(
wallet_id=key_info.wallet.id, wallet_id=key_info.wallet.id,
pending=True, pending=True,
@@ -102,10 +101,11 @@ async def api_payments(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_history( async def api_payments_history(
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
group: DateTrunc = Query("day"), group: DateTrunc = Query("day"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(key_info.wallet.id)
return await get_payments_history(key_info.wallet.id, group, filters) return await get_payments_history(key_info.wallet.id, group, filters)
@@ -118,14 +118,14 @@ async def api_payments_history(
async def api_payments_counting_stats( async def api_payments_counting_stats(
count_by: PaymentCountField = Query("tag"), count_by: PaymentCountField = Query("tag"),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
): ):
if account_id.is_admin_id: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
else: else:
# regular user can only see payments from their wallets # regular user can only see payments from their wallets
for_user_id = account_id.id for_user_id = user.id
return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id) return await get_payment_count_stats(count_by, filters=filters, user_id=for_user_id)
@@ -138,14 +138,14 @@ async def api_payments_counting_stats(
) )
async def api_payments_wallets_stats( async def api_payments_wallets_stats(
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
): ):
if account_id.is_admin_id: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
else: else:
# regular user can only see payments from their wallets # regular user can only see payments from their wallets
for_user_id = account_id.id for_user_id = user.id
return await get_wallets_stats(filters, user_id=for_user_id) return await get_wallets_stats(filters, user_id=for_user_id)
@@ -157,15 +157,15 @@ async def api_payments_wallets_stats(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_daily_stats( async def api_payments_daily_stats(
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)), filters: Filters[PaymentFilters] = Depends(parse_filters(PaymentFilters)),
): ):
if account_id.is_admin_id: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
else: else:
# regular user can only see payments from their wallets # regular user can only see payments from their wallets
for_user_id = account_id.id for_user_id = user.id
return await get_payments_daily_stats(filters, user_id=for_user_id) return await get_payments_daily_stats(filters, user_id=for_user_id)
@@ -178,30 +178,24 @@ async def api_payments_daily_stats(
openapi_extra=generate_filter_params_openapi(PaymentFilters), openapi_extra=generate_filter_params_openapi(PaymentFilters),
) )
async def api_payments_paginated( async def api_payments_paginated(
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
recheck_pending: bool = Query( recheck_pending: bool = Query(
False, description="Force check and update of pending payments." False, description="Force check and update of pending payments."
), ),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
) -> Page[Payment]: ):
async with db.connect() as conn: page = await get_payments_paginated(
page = await get_payments_paginated( wallet_id=key_info.wallet.id,
wallet_id=key_info.wallet.id, filters=filters,
filters=filters, )
conn=conn, if not recheck_pending:
) return page
if not recheck_pending:
return page
payments = [] for payment in page.data:
for payment in page.data: if payment.pending:
if payment.pending: await update_pending_payment(payment)
refreshed_payment = await update_pending_payment(payment, conn=conn)
payments.append(refreshed_payment)
else:
payments.append(payment)
return Page(data=payments, total=page.total) return page
@payment_router.get( @payment_router.get(
@@ -214,19 +208,19 @@ async def api_payments_paginated(
) )
async def api_all_payments_paginated( async def api_all_payments_paginated(
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
): ):
if account_id.is_admin_id: if user.admin:
# admin user can see payments from all wallets # admin user can see payments from all wallets
for_user_id = None for_user_id = None
else: else:
# regular user can only see payments from their wallets # regular user can only see payments from their wallets
for_user_id = account_id.id for_user_id = user.id
async with db.connect() as conn: return await get_payments_paginated(
return await get_payments_paginated( filters=filters,
filters=filters, user_id=for_user_id, conn=conn user_id=for_user_id,
) )
@payment_router.post( @payment_router.post(
@@ -249,10 +243,10 @@ async def api_all_payments_paginated(
) )
async def api_payments_create( async def api_payments_create(
invoice_data: CreateInvoice, invoice_data: CreateInvoice,
key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Payment: ) -> Payment:
wallet_id = key_info.wallet.id wallet_id = wallet.wallet.id
if invoice_data.out is True and key_info.key_type == KeyType.admin: if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11: if not invoice_data.bolt11:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
@@ -280,8 +274,9 @@ async def api_payments_create(
async def api_update_payment_labels( async def api_update_payment_labels(
payment_hash: str, payment_hash: str,
data: UpdatePaymentLabels, data: UpdatePaymentLabels,
key_type: BaseWalletTypeInfo = Depends(require_base_admin_key), key_type: WalletTypeInfo = Depends(require_admin_key),
) -> SimpleStatus: ) -> SimpleStatus:
payment = await get_standalone_payment(payment_hash, wallet_id=key_type.wallet.id) payment = await get_standalone_payment(payment_hash, wallet_id=key_type.wallet.id)
if payment is None: if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.") raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
@@ -337,7 +332,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
return {"paid": False, "status": "failed"} return {"paid": False, "status": "failed"}
try: try:
payment = await update_pending_payment(payment) status = await payment.check_status()
except Exception: except Exception:
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment} return {"paid": False, "details": payment}
@@ -346,7 +341,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return { return {
"paid": payment.success, "paid": payment.success,
"status": f"{payment.status!s}", "status": f"{status!s}",
"preimage": payment.preimage, "preimage": payment.preimage,
"details": payment, "details": payment,
} }
+3 -4
View File
@@ -7,11 +7,10 @@ from fastapi import (
) )
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
from lnbits.core.models.wallets import BaseWalletTypeInfo
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
require_admin_key, require_admin_key,
require_base_invoice_key, require_invoice_key,
) )
from ..crud import ( from ..crud import (
@@ -51,12 +50,12 @@ async def api_create_tinyurl(
description="get a tinyurl by id", description="get a tinyurl by id",
) )
async def api_get_tinyurl( async def api_get_tinyurl(
tinyurl_id: str, key_info: BaseWalletTypeInfo = Depends(require_base_invoice_key) tinyurl_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
): ):
try: try:
tinyurl = await get_tinyurl(tinyurl_id) tinyurl = await get_tinyurl(tinyurl_id)
if tinyurl: if tinyurl:
if tinyurl.wallet == key_info.wallet.id: if tinyurl.wallet == wallet.wallet.id:
return tinyurl return tinyurl
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided." status_code=HTTPStatus.FORBIDDEN, detail="Wrong key provided."
+15 -51
View File
@@ -20,8 +20,6 @@ from lnbits.core.crud import (
update_admin_settings, update_admin_settings,
update_wallet, update_wallet,
) )
from lnbits.core.crud.users import clear_user_id_cache, get_account, update_account
from lnbits.core.crud.wallets import delete_wallet_by_id
from lnbits.core.models import ( from lnbits.core.models import (
AccountFilters, AccountFilters,
AccountOverview, AccountOverview,
@@ -74,7 +72,7 @@ async def api_get_users(
summary="Get user by Id", summary="Get user by Id",
) )
async def api_get_user(user_id: str) -> User: async def api_get_user(user_id: str) -> User:
user = await get_user(user_id, active_only=False) user = await get_user(user_id)
if not user: if not user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return user return user
@@ -117,12 +115,12 @@ async def api_create_user(data: CreateUser) -> CreateUser:
@users_router.put("/user/{user_id}", name="Update user") @users_router.put("/user/{user_id}", name="Update user")
async def api_update_user( async def api_update_user(
user_id: str, data: CreateUser, account: Account = Depends(check_admin) user_id: str, data: CreateUser, user: User = Depends(check_admin)
) -> CreateUser: ) -> CreateUser:
if user_id != data.id: if user_id != data.id:
raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.") raise HTTPException(HTTPStatus.BAD_REQUEST, "User Id missmatch.")
if user_id == settings.super_user and account.id != settings.super_user: if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.", detail="Action only allowed for super user.",
@@ -156,11 +154,14 @@ async def api_update_user(
name="Delete user by Id", name="Delete user by Id",
) )
async def api_users_delete_user( async def api_users_delete_user(
user_id: str, account: Account = Depends(check_admin) user_id: str, user: User = Depends(check_admin)
) -> SimpleStatus: ) -> SimpleStatus:
wallets = await get_wallets(user_id, deleted=False) wallets = await get_wallets(user_id, deleted=False)
for wallet in wallets: if len(wallets) > 0:
await delete_wallet_by_id(wallet.id) raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete user with wallets.",
)
if user_id == settings.super_user: if user_id == settings.super_user:
raise HTTPException( raise HTTPException(
@@ -168,7 +169,7 @@ async def api_users_delete_user(
detail="Cannot delete super user.", detail="Cannot delete super user.",
) )
if user_id in settings.lnbits_admin_users and not account.is_super_user: if user_id in settings.lnbits_admin_users and not user.super_user:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Only super_user can delete admin user.", detail="Only super_user can delete admin user.",
@@ -198,7 +199,7 @@ async def api_users_reset_password(user_id: str) -> str:
return f"reset_key_{reset_key_b64}" return f"reset_key_{reset_key_b64}"
@users_router.put( @users_router.get(
"/user/{user_id}/admin", "/user/{user_id}/admin",
dependencies=[Depends(check_super_user)], dependencies=[Depends(check_super_user)],
name="Give or revoke admin permsisions to a user", name="Give or revoke admin permsisions to a user",
@@ -221,46 +222,9 @@ async def api_users_toggle_admin(user_id: str) -> SimpleStatus:
) )
@users_router.put(
"/user/{user_id}/activate",
name="Activate or deactivate a user",
)
async def api_users_toggle_activated(
user_id: str, admin_account: Account = Depends(check_admin)
) -> SimpleStatus:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot deactivate super user.",
)
if user_id == admin_account.id:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="You cannot deactivate yourself.",
)
if settings.is_admin_user(user_id):
settings.lnbits_admin_users.remove(user_id)
user_account = await get_account(user_id, active_only=False)
if not user_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="User not found.",
)
user_account.activated = not user_account.activated
await update_account(user_account)
await clear_user_id_cache(user_id)
return SimpleStatus(
success=True,
message=f"User {'activated' if user_account.activated else 'deactivated'}.",
)
@users_router.get("/user/{user_id}/wallet", name="Get wallets for user") @users_router.get("/user/{user_id}/wallet", name="Get wallets for user")
async def api_users_get_user_wallet(user_id: str) -> list[Wallet]: async def api_users_get_user_wallet(user_id: str) -> list[Wallet]:
return await get_wallets(user_id, deleted=None) return await get_wallets(user_id)
@users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user") @users_router.post("/user/{user_id}/wallet", name="Create a new wallet for user")
@@ -283,7 +247,7 @@ async def api_users_create_user_wallet(
"/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet" "/user/{user_id}/wallet/{wallet}/undelete", name="Reactivate deleted wallet"
) )
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleStatus: async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> SimpleStatus:
wal = await get_wallet(wallet, deleted=True) wal = await get_wallet(wallet)
if not wal: if not wal:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
@@ -331,7 +295,7 @@ async def api_users_delete_all_user_wallet(user_id: str) -> SimpleStatus:
"The second time it is called will delete the entry from the DB", "The second time it is called will delete the entry from the DB",
) )
async def api_users_delete_user_wallet( async def api_users_delete_user_wallet(
user_id: str, wallet: str, account: Account = Depends(check_admin) user_id: str, wallet: str, user: User = Depends(check_admin)
) -> SimpleStatus: ) -> SimpleStatus:
wal = await get_wallet(wallet) wal = await get_wallet(wallet)
if not wal: if not wal:
@@ -340,7 +304,7 @@ async def api_users_delete_user_wallet(
detail="Wallet does not exist.", detail="Wallet does not exist.",
) )
if user_id == settings.super_user and account.id != settings.super_user: if user_id == settings.super_user and user.id != settings.super_user:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Action only allowed for super user.", detail="Action only allowed for super user.",
+18 -25
View File
@@ -9,14 +9,12 @@ from fastapi import (
) )
from lnbits.core.crud.wallets import ( from lnbits.core.crud.wallets import (
clear_wallet_cache,
create_wallet, create_wallet,
get_wallets_paginated, get_wallets_paginated,
) )
from lnbits.core.models import CreateWallet, KeyType, Wallet, WalletTypeInfo from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
from lnbits.core.models.misc import SimpleStatus from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import Account, AccountId
from lnbits.core.models.wallets import ( from lnbits.core.models.wallets import (
WalletsFilters, WalletsFilters,
WalletSharePermission, WalletSharePermission,
@@ -31,8 +29,7 @@ from lnbits.core.services.wallets import (
) )
from lnbits.db import Filters, Page from lnbits.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_exists, check_user_exists,
check_account_id_exists,
parse_filters, parse_filters,
require_admin_key, require_admin_key,
require_invoice_key, require_invoice_key,
@@ -68,11 +65,11 @@ async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
openapi_extra=generate_filter_params_openapi(WalletsFilters), openapi_extra=generate_filter_params_openapi(WalletsFilters),
) )
async def api_wallets_paginated( async def api_wallets_paginated(
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
filters: Filters = Depends(parse_filters(WalletsFilters)), filters: Filters = Depends(parse_filters(WalletsFilters)),
): ):
page = await get_wallets_paginated( page = await get_wallets_paginated(
user_id=account_id.id, user_id=user.id,
filters=filters, filters=filters,
) )
@@ -88,7 +85,7 @@ async def api_invite_wallet_share(
@wallet_router.delete("/share/invite/{share_request_id}") @wallet_router.delete("/share/invite/{share_request_id}")
async def api_reject_wallet_invitation( async def api_reject_wallet_invitation(
share_request_id: str, invited_user: Account = Depends(check_account_exists) share_request_id: str, invited_user: User = Depends(check_user_exists)
) -> SimpleStatus: ) -> SimpleStatus:
await reject_wallet_invitation(invited_user.id, share_request_id) await reject_wallet_invitation(invited_user.id, share_request_id)
return SimpleStatus(success=True, message="Invitation rejected.") return SimpleStatus(success=True, message="Invitation rejected.")
@@ -127,15 +124,12 @@ async def api_update_wallet_name(
@wallet_router.put("/reset/{wallet_id}") @wallet_router.put("/reset/{wallet_id}")
async def api_reset_wallet_keys( async def api_reset_wallet_keys(
wallet_id: str, wallet_id: str, user: User = Depends(check_user_exists)
account_id: AccountId = Depends(check_account_id_exists),
) -> Wallet: ) -> Wallet:
wallet = await get_wallet(wallet_id) wallet = await get_wallet(wallet_id)
if not wallet or wallet.user != account_id.id: if not wallet or wallet.user != user.id:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
clear_wallet_cache(wallet)
wallet.adminkey = uuid4().hex wallet.adminkey = uuid4().hex
wallet.inkey = uuid4().hex wallet.inkey = uuid4().hex
await update_wallet(wallet) await update_wallet(wallet)
@@ -181,10 +175,10 @@ async def api_update_wallet(
@wallet_router.delete("/{wallet_id}") @wallet_router.delete("/{wallet_id}")
async def api_delete_wallet( async def api_delete_wallet(
wallet_id: str, account_id: AccountId = Depends(check_account_id_exists) wallet_id: str, user: User = Depends(check_user_exists)
) -> None: ) -> None:
wallet = await get_wallet(wallet_id) wallet = await get_wallet(wallet_id)
if not wallet or wallet.user != account_id.id: if not wallet or wallet.user != user.id:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found") raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
await delete_wallet( await delete_wallet(
@@ -195,14 +189,11 @@ async def api_delete_wallet(
@wallet_router.post("") @wallet_router.post("")
async def api_create_wallet( async def api_create_wallet(
data: CreateWallet, account_id: AccountId = Depends(check_account_id_exists) data: CreateWallet,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet: ) -> Wallet:
if data.wallet_type == WalletType.LIGHTNING:
if data.wallet_type not in list(WalletType): return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Wallet type {data.wallet_type} does not exist.",
)
if data.wallet_type == WalletType.LIGHTNING_SHARED: if data.wallet_type == WalletType.LIGHTNING_SHARED:
if not data.shared_wallet_id: if not data.shared_wallet_id:
@@ -211,9 +202,11 @@ async def api_create_wallet(
"Shared wallet ID is required for shared wallets.", "Shared wallet ID is required for shared wallets.",
) )
return await create_lightning_shared_wallet( return await create_lightning_shared_wallet(
user_id=account_id.id, user_id=key_info.wallet.user,
source_wallet_id=data.shared_wallet_id, source_wallet_id=data.shared_wallet_id,
) )
# default WalletType.LIGHTNING: raise HTTPException(
return await create_wallet(user_id=account_id.id, wallet_name=data.name) HTTPStatus.BAD_REQUEST,
f"Unknown wallet type: {data.wallet_type}.",
)
+7 -7
View File
@@ -15,9 +15,9 @@ from lnbits.core.models import (
CreateWebPushSubscription, CreateWebPushSubscription,
WebPushSubscription, WebPushSubscription,
) )
from lnbits.core.models.users import AccountId from lnbits.core.models.users import User
from lnbits.decorators import ( from lnbits.decorators import (
check_account_id_exists, check_user_exists,
) )
from ..crud import ( from ..crud import (
@@ -33,20 +33,20 @@ webpush_router = APIRouter(prefix="/api/v1/webpush", tags=["Webpush"])
async def api_create_webpush_subscription( async def api_create_webpush_subscription(
request: Request, request: Request,
data: CreateWebPushSubscription, data: CreateWebPushSubscription,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
) -> WebPushSubscription: ) -> WebPushSubscription:
try: try:
subscription = json.loads(data.subscription) subscription = json.loads(data.subscription)
endpoint = subscription["endpoint"] endpoint = subscription["endpoint"]
host = urlparse(str(request.url)).netloc host = urlparse(str(request.url)).netloc
subscription = await get_webpush_subscription(endpoint, account_id.id) subscription = await get_webpush_subscription(endpoint, user.id)
if subscription: if subscription:
return subscription return subscription
else: else:
return await create_webpush_subscription( return await create_webpush_subscription(
endpoint, endpoint,
account_id.id, user.id,
data.subscription, data.subscription,
host, host,
) )
@@ -61,13 +61,13 @@ async def api_create_webpush_subscription(
@webpush_router.delete("", status_code=HTTPStatus.OK) @webpush_router.delete("", status_code=HTTPStatus.OK)
async def api_delete_webpush_subscription( async def api_delete_webpush_subscription(
request: Request, request: Request,
account_id: AccountId = Depends(check_account_id_exists), user: User = Depends(check_user_exists),
): ):
try: try:
endpoint = unquote( endpoint = unquote(
base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8") base64.b64decode(str(request.query_params.get("endpoint"))).decode("utf-8")
) )
count = await delete_webpush_subscription(endpoint, account_id.id) count = await delete_webpush_subscription(endpoint, user.id)
return {"count": count} return {"count": count}
except Exception as exc: except Exception as exc:
logger.debug(exc) logger.debug(exc)
+37 -9
View File
@@ -12,6 +12,7 @@ from typing import Any, Generic, Literal, TypeVar, get_origin
from loguru import logger from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator from pydantic import BaseModel, ValidationError, root_validator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text from sqlalchemy.sql import text
@@ -55,6 +56,14 @@ def compat_timestamp_placeholder(key: str):
return f":{key}" return f":{key}"
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime:
return compat_timestamp_placeholder(field)
else:
return f":{field}"
class Compat: class Compat:
type: str | None = "<inherited>" type: str | None = "<inherited>"
schema: str | None = "<inherited>" schema: str | None = "<inherited>"
@@ -317,7 +326,30 @@ class Database(Compat):
self.engine: AsyncEngine = create_async_engine( self.engine: AsyncEngine = create_async_engine(
database_uri, echo=settings.debug_database database_uri, echo=settings.debug_database
) )
if self.type in {POSTGRES, COCKROACH}:
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_date(value) -> datetime:
if value is None:
value = "1970-01-01 00:00:00"
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return datetime.strptime(value, f)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime,
decoder=_parse_date,
schema="pg_catalog",
)
)
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}") logger.trace(f"database {self.type} added for {self.name}")
@asynccontextmanager @asynccontextmanager
@@ -630,7 +662,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
placeholders = [] placeholders = []
keys = model_to_dict(model).keys() keys = model_to_dict(model).keys()
for field in keys: for field in keys:
placeholders.append(f":{field}") placeholders.append(get_placeholder(model, field))
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword) # add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields = ", ".join([f'"{key}"' for key in keys]) fields = ", ".join([f'"{key}"' for key in keys])
values = ", ".join(placeholders) values = ", ".join(placeholders)
@@ -648,8 +680,9 @@ def update_query(
""" """
fields = [] fields = []
for field in model_to_dict(model).keys(): for field in model_to_dict(model).keys():
placeholder = get_placeholder(model, field)
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword) # add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields.append(f'"{field}" = :{field}') fields.append(f'"{field}" = {placeholder}')
query = ", ".join(fields) query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608 return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
@@ -667,12 +700,7 @@ def model_to_dict(model: BaseModel) -> dict:
if model.__fields__[key].field_info.extra.get("no_database", False): if model.__fields__[key].field_info.extra.get("no_database", False):
continue continue
if isinstance(value, datetime): if isinstance(value, datetime):
if DB_TYPE == SQLITE: _dict[key] = value.timestamp()
_dict[key] = value.timestamp()
else:
# remove tz. postgres and cockroach TIMESTAMP is not tz aware
# so it will throw if we dont remove the UTC.
_dict[key] = value.replace(tzinfo=None)
continue continue
if ( if (
type(type_) is type(BaseModel) type(type_) is type(BaseModel)
@@ -728,7 +756,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if DB_TYPE == SQLITE: if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc) _dict[key] = datetime.fromtimestamp(value, timezone.utc)
else: else:
_dict[key] = value.replace(tzinfo=timezone.utc) _dict[key] = value
continue continue
if issubclass(type_, BaseModel): if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value) _dict[key] = dict_to_submodel(type_, value)
+53 -222
View File
@@ -19,8 +19,6 @@ from lnbits.core.crud import (
get_wallet_for_key, get_wallet_for_key,
) )
from lnbits.core.crud.users import get_user_access_control_lists from lnbits.core.crud.users import get_user_access_control_lists
from lnbits.core.crud.wallets import get_base_wallet_for_key
from lnbits.core.db import db
from lnbits.core.models import ( from lnbits.core.models import (
AccessTokenPayload, AccessTokenPayload,
Account, Account,
@@ -29,12 +27,9 @@ from lnbits.core.models import (
User, User,
WalletTypeInfo, WalletTypeInfo,
) )
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import BaseWallet, BaseWalletTypeInfo
from lnbits.db import Connection, Filter, Filters, TFilterModel from lnbits.db import Connection, Filter, Filters, TFilterModel
from lnbits.helpers import normalize_path, path_segments, sha256s from lnbits.helpers import normalize_path, path_segments
from lnbits.settings import AuthMethods, settings from lnbits.settings import AuthMethods, settings
from lnbits.utils.cache import cache
oauth2_scheme = OAuth2PasswordBearer( oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="api/v1/auth", tokenUrl="api/v1/auth",
@@ -57,7 +52,7 @@ api_key_query = APIKeyQuery(
) )
class BaseKeyChecker(SecurityBase): class KeyChecker(SecurityBase):
def __init__( def __init__(
self, self,
api_key: str | None = None, api_key: str | None = None,
@@ -82,7 +77,8 @@ class BaseKeyChecker(SecurityBase):
) )
self.model: APIKey = openapi_model # type: ignore self.model: APIKey = openapi_model # type: ignore
def _extract_key_value(self, request): async def __call__(self, request: Request) -> WalletTypeInfo:
key_value = ( key_value = (
self._api_key self._api_key
if self._api_key if self._api_key
@@ -95,88 +91,27 @@ class BaseKeyChecker(SecurityBase):
detail="No Api Key provided.", detail="No Api Key provided.",
) )
return key_value wallet = await get_wallet_for_key(key_value)
async def _extract_key_type(self, key_value: str, wallet: BaseWallet) -> KeyType: if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found.",
)
request.scope["user_id"] = wallet.user
if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value: if self.expected_key_type is KeyType.admin and wallet.adminkey != key_value:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, status_code=HTTPStatus.FORBIDDEN,
detail="Invalid adminkey.", detail="Invalid adminkey.",
) )
await _check_user_extension_access(wallet.user, request["path"])
key_type = KeyType.admin if wallet.adminkey == key_value else KeyType.invoice key_type = KeyType.admin if wallet.adminkey == key_value else KeyType.invoice
return key_type
class KeyChecker(BaseKeyChecker):
def __init__(
self,
api_key: str | None = None,
expected_key_type: KeyType | None = None,
):
super().__init__(api_key, expected_key_type)
async def __call__(self, request: Request) -> WalletTypeInfo:
key_value = self._extract_key_value(request)
async with db.connect() as conn:
wallet = await get_wallet_for_key(key_value, conn=conn)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found.",
)
request.scope["user_id"] = wallet.user
await _check_user_access(request, wallet.user, conn=conn)
key_type = await self._extract_key_type(key_value, wallet)
return WalletTypeInfo(key_type, wallet) return WalletTypeInfo(key_type, wallet)
class LightKeyChecker(BaseKeyChecker):
def __init__(
self,
api_key: str | None = None,
expected_key_type: KeyType | None = None,
):
super().__init__(api_key, expected_key_type)
async def __call__(self, request: Request) -> BaseWalletTypeInfo:
key_value = self._extract_key_value(request)
cache_key = f"auth:x-api-key:{key_value}"
cache_time = settings.auth_authentication_cache_minutes * 60
async with db.connect() as conn:
if cache_time > 0:
key_info: BaseWalletTypeInfo | None = cache.get(cache_key)
if key_info:
request.scope["user_id"] = key_info.wallet.user
await _check_user_access(request, key_info.wallet.user, conn=conn)
return key_info
wallet = await get_base_wallet_for_key(key_value, conn=conn)
if not wallet:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet not found.",
)
request.scope["user_id"] = wallet.user
await _check_user_access(request, wallet.user, conn=conn)
key_type = await self._extract_key_type(key_value, wallet)
key_info = BaseWalletTypeInfo(key_type, wallet)
if cache_time > 0:
cache.set(cache_key, key_info, expiry=cache_time)
cache.set(f"auth:wallet:{wallet.id}", wallet, expiry=cache_time)
return key_info
async def require_admin_key( async def require_admin_key(
request: Request, request: Request,
api_key_header: str = Security(api_key_header), api_key_header: str = Security(api_key_header),
@@ -189,18 +124,6 @@ async def require_admin_key(
return await check(request) return await check(request)
async def require_base_admin_key(
request: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> BaseWalletTypeInfo:
check: LightKeyChecker = LightKeyChecker(
api_key=api_key_header or api_key_query,
expected_key_type=KeyType.admin,
)
return await check(request)
async def require_invoice_key( async def require_invoice_key(
request: Request, request: Request,
api_key_header: str = Security(api_key_header), api_key_header: str = Security(api_key_header),
@@ -213,18 +136,6 @@ async def require_invoice_key(
return await check(request) return await check(request)
async def require_base_invoice_key(
request: Request,
api_key_header: str = Security(api_key_header),
api_key_query: str = Security(api_key_query),
) -> BaseWalletTypeInfo:
check: LightKeyChecker = LightKeyChecker(
api_key=api_key_header or api_key_query,
expected_key_type=KeyType.invoice,
)
return await check(request)
async def check_access_token( async def check_access_token(
header_access_token: Annotated[str | None, Depends(oauth2_scheme)], header_access_token: Annotated[str | None, Depends(oauth2_scheme)],
cookie_access_token: Annotated[str | None, Cookie()] = None, cookie_access_token: Annotated[str | None, Cookie()] = None,
@@ -233,96 +144,33 @@ async def check_access_token(
return header_access_token or cookie_access_token or bearer_access_token return header_access_token or cookie_access_token or bearer_access_token
async def check_account_id_exists(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> AccountId:
cache_key: str | None = None
if access_token:
cache_key = f"auth:access_token:{sha256s(access_token)}"
elif usr:
cache_key = f"auth:user_id:{sha256s(usr.hex)}"
async with db.connect() as conn:
if cache_key and settings.auth_authentication_cache_minutes > 0:
account_id = cache.get(cache_key)
if account_id:
r.scope["user_id"] = account_id.id
await _check_user_access(r, account_id.id, conn=conn)
return account_id
account = await _check_account_exists(r, access_token, usr, conn=conn)
account_id = AccountId(id=account.id)
if cache_key and settings.auth_authentication_cache_minutes > 0:
cache.set(
cache_key,
account_id,
expiry=settings.auth_authentication_cache_minutes * 60,
)
cache.set(f"auth:user:cache_key:{sha256s(account.id)}", cache_key)
return account_id
async def check_account_exists(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
) -> Account:
return await _check_account_exists(r, access_token, usr)
async def _check_account_exists(
r: Request,
access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None,
conn: Connection | None = None,
) -> Account:
"""
Check that the account exists based on access token or user id.
More performant version of `check_user_exists`.
Unlike `check_user_exists`, this function:
- does not fetch the user wallets
- caches the account info based on settings cache time
"""
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
if access_token:
account = await _get_account_from_token(
access_token, r["path"], r["method"], conn=new_conn
)
elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
account = await get_account(usr.hex, conn=new_conn)
if account and account.is_admin:
raise HTTPException(
HTTPStatus.FORBIDDEN, "User id only access for admins is forbidden."
)
else:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Missing user ID or access token."
)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
r.scope["user_id"] = account.id
await _check_user_access(r, account.id, conn=new_conn)
return account
async def check_user_exists( async def check_user_exists(
r: Request, r: Request,
access_token: Annotated[str | None, Depends(check_access_token)], access_token: Annotated[str | None, Depends(check_access_token)],
usr: UUID4 | None = None, usr: UUID4 | None = None,
) -> User: ) -> User:
async with db.connect() as conn: if access_token:
account = await _check_account_exists(r, access_token, usr, conn=conn) account = await _get_account_from_token(access_token, r["path"], r["method"])
user = await get_user_from_account(account, conn=conn) elif usr and settings.is_auth_method_allowed(AuthMethods.user_id_only):
if not user: account = await get_account(usr.hex)
if account and account.is_admin:
raise HTTPException(
HTTPStatus.FORBIDDEN, "User id only access for admins is forbidden."
)
else:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
r.scope["user_id"] = account.id
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
user = await get_user_from_account(account)
if not user:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
await _check_user_extension_access(user.id, r["path"])
return user return user
@@ -350,36 +198,29 @@ async def access_token_payload(
return AccessTokenPayload(**payload) return AccessTokenPayload(**payload)
async def check_admin( async def check_admin(user: Annotated[User, Depends(check_user_exists)]) -> User:
account: Annotated[Account, Depends(check_account_exists)], if user.id != settings.super_user and user.id not in settings.lnbits_admin_users:
) -> Account:
if (
account.id != settings.super_user
and account.id not in settings.lnbits_admin_users
):
raise HTTPException( raise HTTPException(
HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges." HTTPStatus.FORBIDDEN, "User not authorized. No admin privileges."
) )
if not account.has_password: if not user.has_password:
raise HTTPException( raise HTTPException(
HTTPStatus.FORBIDDEN, "Admin users must have credentials configured." HTTPStatus.FORBIDDEN, "Admin users must have credentials configured."
) )
return account return user
async def check_super_user( async def check_super_user(user: Annotated[User, Depends(check_user_exists)]) -> User:
account: Annotated[Account, Depends(check_admin)], if user.id != settings.super_user:
) -> Account:
if account.id != settings.super_user:
raise HTTPException( raise HTTPException(
HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges." HTTPStatus.FORBIDDEN, "User not authorized. No super user privileges."
) )
if not account.has_password: if not user.has_password:
raise HTTPException( raise HTTPException(
HTTPStatus.FORBIDDEN, "Super user must have credentials configured." HTTPStatus.FORBIDDEN, "Super user must have credentials configured."
) )
return account return user
def parse_filters(model: type[TFilterModel]): def parse_filters(model: type[TFilterModel]):
@@ -439,17 +280,9 @@ async def check_user_extension_access(
return SimpleStatus(success=True, message="OK") return SimpleStatus(success=True, message="OK")
async def _check_user_access(r: Request, user_id: str, conn: Connection | None = None): async def _check_user_extension_access(user_id: str, path: str):
if not settings.is_user_allowed(user_id):
raise HTTPException(HTTPStatus.FORBIDDEN, "User not allowed.")
await _check_user_extension_access(user_id, r["path"], conn=conn)
async def _check_user_extension_access(
user_id: str, path: str, conn: Connection | None = None
):
ext_id = path_segments(path)[0] ext_id = path_segments(path)[0]
status = await check_user_extension_access(user_id, ext_id, conn=conn) status = await check_user_extension_access(user_id, ext_id)
if not status.success: if not status.success:
raise HTTPException( raise HTTPException(
HTTPStatus.FORBIDDEN, HTTPStatus.FORBIDDEN,
@@ -458,12 +291,12 @@ async def _check_user_extension_access(
async def _get_account_from_token( async def _get_account_from_token(
access_token: str, path: str, method: str, conn: Connection | None = None access_token: str, path: str, method: str
) -> Account | None: ) -> Account | None:
try: try:
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"]) payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
return await _get_account_from_jwt_payload( return await _get_account_from_jwt_payload(
AccessTokenPayload(**payload), path, method, conn=conn AccessTokenPayload(**payload), path, method
) )
except jwt.ExpiredSignatureError as exc: except jwt.ExpiredSignatureError as exc:
@@ -476,35 +309,33 @@ async def _get_account_from_token(
async def _get_account_from_jwt_payload( async def _get_account_from_jwt_payload(
payload: AccessTokenPayload, path: str, method: str, conn: Connection | None = None payload: AccessTokenPayload, path: str, method: str
) -> Account | None: ) -> Account | None:
account = None account = None
if payload.sub: if payload.sub:
account = await get_account_by_username(payload.sub, conn=conn) account = await get_account_by_username(payload.sub)
elif payload.usr: elif payload.usr:
account = await get_account(payload.usr, conn=conn) account = await get_account(payload.usr)
elif payload.email: elif payload.email:
account = await get_account_by_email(payload.email, conn=conn) account = await get_account_by_email(payload.email)
if not account: if not account:
return None return None
if payload.api_token_id: if payload.api_token_id:
await _check_account_api_access( await _check_account_api_access(account.id, payload.api_token_id, path, method)
account.id, payload.api_token_id, path, method, conn=conn
)
return account return account
async def _check_account_api_access( async def _check_account_api_access(
user_id: str, token_id: str, path: str, method: str, conn: Connection | None = None user_id: str, token_id: str, path: str, method: str
): ):
segments = path.split("/") segments = path.split("/")
if len(segments) < 3: if len(segments) < 3:
raise HTTPException(HTTPStatus.FORBIDDEN, "Not an API endpoint.") raise HTTPException(HTTPStatus.FORBIDDEN, "Not an API endpoint.")
acls = await get_user_access_control_lists(user_id, conn=conn) acls = await get_user_access_control_lists(user_id)
acl = acls.get_acl_by_token_id(token_id) acl = acls.get_acl_by_token_id(token_id)
if not acl: if not acl:
raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.") raise HTTPException(HTTPStatus.FORBIDDEN, "Invalid token id.")
+2 -4
View File
@@ -109,7 +109,7 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
logger.error(f"RequestValidationError: {exc!s}") logger.error(f"RequestValidationError: {exc!s}")
return render_html_error(request, exc) or JSONResponse( return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
content={"detail": [dict(e) for e in exc.errors()]}, content={"detail": str(exc)},
) )
@app.exception_handler(HTTPException) @app.exception_handler(HTTPException)
@@ -138,9 +138,7 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
@app.exception_handler(404) @app.exception_handler(404)
async def error_handler_404(request: Request, exc: HTTPException): async def error_handler_404(request: Request, exc: HTTPException):
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
if not request.url.path.endswith("routes.json"):
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
if not _is_browser_request(request): if not _is_browser_request(request):
return JSONResponse( return JSONResponse(
-3
View File
@@ -8,7 +8,6 @@ from loguru import logger
from lnbits.fiat.base import FiatProvider from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings from lnbits.settings import settings
from .paypal import PayPalWallet
from .stripe import StripeWallet from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat") fiat_module = importlib.import_module("lnbits.fiat")
@@ -16,7 +15,6 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum): class FiatProviderType(Enum):
stripe = "StripeWallet" stripe = "StripeWallet"
paypal = "PayPalWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None: async def get_fiat_provider(name: str) -> FiatProvider | None:
@@ -51,6 +49,5 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [ __all__ = [
"PayPalWallet",
"StripeWallet", "StripeWallet",
] ]
-338
View File
@@ -1,338 +0,0 @@
import asyncio
import json
import time
from collections.abc import AsyncGenerator
from typing import Any
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
class PayPalCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
cancel_url: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class PayPalCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
checkout: PayPalCheckoutOptions | None = None
class PayPalWallet(FiatProvider):
"""https://developer.paypal.com/api/rest/"""
def __init__(self):
logger.debug("Initializing PayPalWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.paypal_api_endpoint:
raise ValueError("Cannot initialize PayPalWallet: missing endpoint.")
if not settings.paypal_client_id:
raise ValueError("Cannot initialize PayPalWallet: missing client id.")
if not settings.paypal_client_secret:
raise ValueError("Cannot initialize PayPalWallet: missing client secret.")
self.endpoint = normalize_endpoint(settings.paypal_api_endpoint)
self.headers = {
"User-Agent": f"PayPal Alan:{settings.version}",
}
self._access_token: str | None = None
self._token_expires_at: float = 0
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("PayPalWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing PayPal wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
await self._ensure_access_token()
return FiatStatusResponse(balance=0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if opts is None:
return FiatInvoiceResponse(ok=False, error_message="Invalid PayPal options")
try:
await self._ensure_access_token()
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message="Unable to authenticate."
)
co = opts.checkout or PayPalCheckoutOptions()
success_url = (
co.success_url
or settings.paypal_payment_success_url
or "https://lnbits.com"
)
cancel_url = co.cancel_url or success_url
order_data = {
"intent": "CAPTURE",
"purchase_units": [
{
"amount": {
"currency_code": currency.upper(),
"value": f"{amount:.2f}",
},
"custom_id": payment_hash[:127], # PayPal limit
"invoice_id": payment_hash[:127],
"description": memo or "LNbits Invoice",
}
],
"application_context": {
"return_url": success_url,
"cancel_url": cancel_url,
"shipping_preference": "NO_SHIPPING",
"user_action": "PAY_NOW",
},
}
try:
r = await self.client.post(
"/v2/checkout/orders", json=order_data, headers=self._auth_headers()
)
r.raise_for_status()
data = r.json()
order_id = data.get("id")
approval_url = self._get_approval_url(data.get("links") or [])
if not order_id or not approval_url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing id or approval url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"fiat_paypal_{order_id}",
payment_request=approval_url,
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
success_url = (
payment_options.success_url
or settings.paypal_payment_success_url
or "https://lnbits.com"
)
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
payment_options.extra = payment_options.extra or {}
payment_options.extra["subscription_request_id"] = (
payment_options.subscription_request_id
)
try:
await self._ensure_access_token()
payload = {
"plan_id": subscription_id,
"quantity": str(quantity),
"custom_id": self._serialize_metadata(payment_options),
"application_context": {
"return_url": success_url,
"cancel_url": success_url,
},
}
r = await self.client.post(
"/v1/billing/subscriptions",
json=payload,
headers=self._auth_headers(),
)
r.raise_for_status()
data = r.json()
approval_url = self._get_approval_url(data.get("links") or [])
if not approval_url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing approval url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=approval_url,
subscription_request_id=payment_options.subscription_request_id,
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
await self._ensure_access_token()
r = await self.client.post(
f"/v1/billing/subscriptions/{subscription_id}/cancel",
json={"reason": f"Cancelled by {correlation_id}"},
headers=self._auth_headers(),
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("PayPal does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
await self._ensure_access_token()
order_id = self._normalize_paypal_id(checking_id)
r = await self.client.get(
f"/v2/checkout/orders/{order_id}", headers=self._auth_headers()
)
r.raise_for_status()
return self._status_from_order(r.json())
except Exception as exc:
logger.debug(f"Error getting PayPal order status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("PayPal does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"PayPal does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("status") or "").upper()
if status in ["COMPLETED", "APPROVED"]:
return FiatPaymentSuccessStatus()
if status in ["VOIDED", "CANCELLED", "CANCELED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
def _normalize_paypal_id(self, checking_id: str) -> str:
return (
checking_id.replace("fiat_paypal_", "", 1)
if checking_id.startswith("fiat_paypal_")
else checking_id
)
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
) -> str:
md = {
"wallet_id": payment_options.wallet_id,
"memo": payment_options.memo,
"extra": payment_options.extra,
"tag": payment_options.tag,
"subscription_request_id": payment_options.subscription_request_id,
}
raw = json.dumps(md)
return raw[:127] # PayPal custom_id limit
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> PayPalCreateInvoiceOptions | None:
try:
return PayPalCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid PayPal options: {e}")
return None
async def _ensure_access_token(self):
if self._access_token and time.time() < self._token_expires_at:
return
r = await self.client.post(
"/v1/oauth2/token",
data={"grant_type": "client_credentials"},
auth=(settings.paypal_client_id or "", settings.paypal_client_secret or ""),
headers={"Accept": "application/json"},
)
r.raise_for_status()
data = r.json()
token = data.get("access_token")
expires_in = int(data.get("expires_in") or 300)
if not token:
raise ValueError("Unable to retrieve PayPal access token.")
self._access_token = token
self._token_expires_at = time.time() + expires_in - 30
def _auth_headers(self) -> dict[str, str]:
return {**self.headers, "Authorization": f"Bearer {self._access_token}"}
def _get_approval_url(self, links: list[dict[str, Any]]) -> str | None:
for link in links:
if link.get("rel") == "approve":
return link.get("href")
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.paypal_api_endpoint),
str(settings.paypal_client_id),
str(settings.paypal_client_secret),
str(settings.paypal_webhook_id),
]
)
+2 -23
View File
@@ -35,7 +35,6 @@ class StripeTerminalOptions(BaseModel):
capture_method: Literal["automatic", "manual"] = "automatic" capture_method: Literal["automatic", "manual"] = "automatic"
metadata: dict[str, str] = Field(default_factory=dict) metadata: dict[str, str] = Field(default_factory=dict)
reader_id: str | None = None
class StripeCheckoutOptions(BaseModel): class StripeCheckoutOptions(BaseModel):
@@ -171,7 +170,7 @@ class StripeWallet(FiatProvider):
("line_items[0][price]", subscription_id), ("line_items[0][price]", subscription_id),
("line_items[0][quantity]", f"{quantity}"), ("line_items[0][quantity]", f"{quantity}"),
] ]
subscription_data = {**payment_options.dict(), "alan_action": "subscription"} subscription_data = {**payment_options.dict(), "lnbits_action": "subscription"}
subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {}) subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {})
form_data += self._encode_metadata( form_data += self._encode_metadata(
@@ -296,15 +295,6 @@ class StripeWallet(FiatProvider):
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def _process_terminal_payment_intent(
self, reader_id: str, payment_intent_id: str
) -> None:
data = {"payment_intent": payment_intent_id}
r = await self.client.post(
f"/v1/terminal/readers/{reader_id}/process_payment_intent", data=data
)
r.raise_for_status()
async def _create_checkout_invoice( async def _create_checkout_invoice(
self, self,
amount_cents: int, amount_cents: int,
@@ -325,7 +315,7 @@ class StripeWallet(FiatProvider):
("mode", "payment"), ("mode", "payment"),
("success_url", success_url), ("success_url", success_url),
("metadata[payment_hash]", payment_hash), ("metadata[payment_hash]", payment_hash),
("metadata[alan_action]", "invoice"), ("metadata[lnbits_action]", "invoice"),
("line_items[0][price_data][currency]", currency.lower()), ("line_items[0][price_data][currency]", currency.lower()),
("line_items[0][price_data][product_data][name]", line_item_name), ("line_items[0][price_data][product_data][name]", line_item_name),
("line_items[0][price_data][unit_amount]", str(amount_cents)), ("line_items[0][price_data][unit_amount]", str(amount_cents)),
@@ -388,17 +378,6 @@ class StripeWallet(FiatProvider):
ok=False, ok=False,
error_message="Error: missing PaymentIntent or client_secret", error_message="Error: missing PaymentIntent or client_secret",
) )
if term.reader_id:
try:
await self._process_terminal_payment_intent(term.reader_id, pi_id)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False,
error_message=(
"Error: unable to process PaymentIntent on reader"
),
)
return FiatInvoiceResponse( return FiatInvoiceResponse(
ok=True, checking_id=pi_id, payment_request=client_secret ok=True, checking_id=pi_id, payment_request=client_secret
) )
+48 -17
View File
@@ -68,13 +68,55 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
folders.extend(additional_folders) folders.extend(additional_folders)
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders)) t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))
t.env.globals["static_url_for"] = static_url_for t.env.globals["static_url_for"] = static_url_for
t.env.globals["normalize_path"] = normalize_path
# used in base.html window_settings = {
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title "AD_SPACE": settings.lnbits_ad_space,
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon "AD_SPACE_ENABLED": settings.lnbits_ad_space_enabled,
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True) "AD_SPACE_TITLE": settings.lnbits_ad_space_title,
t.env.globals["CURRENCIES"] = list(currencies.keys()) "EXTENSIONS": list(settings.lnbits_installed_extensions_ids),
"HIDE_API": settings.lnbits_hide_api,
"SITE_TITLE": settings.lnbits_site_title,
"SITE_TAGLINE": settings.lnbits_site_tagline,
"SITE_DESCRIPTION": settings.lnbits_site_description,
"LNBITS_ADMIN_UI": settings.lnbits_admin_ui,
"LNBITS_AUDIT_ENABLED": settings.lnbits_audit_enabled,
"LNBITS_AUTH_METHODS": settings.auth_allowed_methods,
"LNBITS_AUTH_KEYCLOAK_ORG": settings.keycloak_client_custom_org,
"LNBITS_AUTH_KEYCLOAK_ICON": settings.keycloak_client_custom_icon,
"LNBITS_CUSTOM_IMAGE": settings.lnbits_custom_image,
"LNBITS_CUSTOM_BADGE": settings.lnbits_custom_badge,
"LNBITS_CUSTOM_BADGE_COLOR": settings.lnbits_custom_badge_color,
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": settings.lnbits_extensions_deactivate_all,
"LNBITS_NEW_ACCOUNTS_ALLOWED": settings.new_accounts_allowed,
"LNBITS_NODE_UI": settings.lnbits_node_ui and settings.has_nodemanager,
"LNBITS_NODE_UI_AVAILABLE": settings.has_nodemanager,
"LNBITS_QR_LOGO": settings.lnbits_qr_logo,
"LNBITS_SERVICE_FEE": settings.lnbits_service_fee,
"LNBITS_SERVICE_FEE_MAX": settings.lnbits_service_fee_max,
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
"LNBITS_VERSION": settings.version,
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
"LNBITS_DEFAULT_THEME": settings.lnbits_default_theme,
"LNBITS_DEFAULT_BORDER": settings.lnbits_default_border,
"LNBITS_DEFAULT_GRADIENT": settings.lnbits_default_gradient,
"LNBITS_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
"LNBITS_DENOMINATION": settings.lnbits_denomination,
"has_holdinvoice": settings.has_holdinvoice,
"LNBITS_NOSTR_CONFIGURED": settings.is_nostr_notifications_configured(),
"LNBITS_TELEGRAM_CONFIGURED": settings.is_telegram_notifications_configured(),
"LNBITS_EXT_BUILDER": settings.lnbits_extensions_builder_activate_non_admins,
"LNBITS_CURRENCIES": list(currencies.keys()),
"LNBITS_ALLOWED_CURRENCIES": settings.lnbits_allowed_currencies,
}
t.env.globals["WINDOW_SETTINGS"] = window_settings
for key, value in window_settings.items():
t.env.globals[key] = value
if settings.bundle_assets: if settings.bundle_assets:
t.env.globals["INCLUDED_JS"] = ["bundle.min.js"] t.env.globals["INCLUDED_JS"] = ["bundle.min.js"]
@@ -88,9 +130,6 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
t.env.globals["INCLUDED_CSS"] = vendor_files["css"] t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"] t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
# backwards compatibility for extensions (tpos)
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
return t return t
@@ -348,14 +387,6 @@ def camel_to_snake(name: str) -> str:
return name.lower() return name.lower()
def snake_to_camel(name: str, capitalize_first: bool | None = False) -> str:
components = name.split("_")
camel = components[0] + "".join(x.capitalize() for x in components[1:])
if capitalize_first:
camel = camel[0].upper() + camel[1:]
return camel
def is_camel_case(v: str) -> bool: def is_camel_case(v: str) -> bool:
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
+4 -173
View File
@@ -41,14 +41,6 @@ class UsersSettings(LNbitsSettings):
lnbits_admin_users: list[str] = Field(default=[]) lnbits_admin_users: list[str] = Field(default=[])
lnbits_allowed_users: list[str] = Field(default=[]) lnbits_allowed_users: list[str] = Field(default=[])
lnbits_allow_new_accounts: bool = Field(default=True) lnbits_allow_new_accounts: bool = Field(default=True)
lnbits_require_user_activation: bool = Field(default=False)
lnbits_user_activation_by_email: bool = Field(default=False)
lnbits_user_activation_by_payment: bool = Field(default=False)
lnbits_user_activation_by_invitation_code: bool = Field(default=False)
lnbits_register_reusable_activation_code: str = Field(default="")
lnbits_register_one_time_activation_codes: list[str] = Field(default=[])
@property @property
def new_accounts_allowed(self) -> bool: def new_accounts_allowed(self) -> bool:
@@ -60,13 +52,6 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_user_default_extensions: list[str] = Field(default=[]) lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False) lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False) lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_extensions_reviews_url: str = Field(
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
description="""
URL for the paid reviews.
Regular users can view this URL (not secret).
""",
)
lnbits_extensions_manifests: list[str] = Field( lnbits_extensions_manifests: list[str] = Field(
default=[ default=[
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json" "https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
@@ -260,11 +245,6 @@ class ThemesSettings(LNbitsSettings):
) )
lnbits_show_home_page_elements: bool = Field(default=True) lnbits_show_home_page_elements: bool = Field(default=True)
lnbits_default_wallet_name: str = Field(default="LNbits wallet") lnbits_default_wallet_name: str = Field(default="LNbits wallet")
lnbits_wallet_featured_button_label: str | None = Field(default=None)
lnbits_wallet_featured_button_url: str | None = Field(default=None)
lnbits_wallet_featured_button_icon: str | None = Field(default=None)
lnbits_custom_badge: str | None = Field(default=None) lnbits_custom_badge: str | None = Field(default=None)
lnbits_custom_badge_color: str = Field(default="warning") lnbits_custom_badge_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field( lnbits_theme_options: list[str] = Field(
@@ -290,7 +270,6 @@ class ThemesSettings(LNbitsSettings):
lnbits_allowed_currencies: list[str] = Field(default=[]) lnbits_allowed_currencies: list[str] = Field(default=[])
lnbits_default_accounting_currency: str | None = Field(default=None) lnbits_default_accounting_currency: str | None = Field(default=None)
lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png") lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png")
lnbits_apple_touch_icon: str | None = Field(default=None)
lnbits_default_reaction: str = Field(default="confettiBothSides") lnbits_default_reaction: str = Field(default="confettiBothSides")
lnbits_default_theme: str = Field(default="salvador") lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border") lnbits_default_border: str = Field(default="hard-border")
@@ -319,10 +298,6 @@ class AssetSettings(LNbitsSettings):
"heic", "heic",
"heif", "heif",
"heics", "heics",
"text/plain",
"text/json" "text/xml",
"application/json",
"application/pdf",
] ]
) )
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0) lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -332,12 +307,6 @@ class AssetSettings(LNbitsSettings):
lnbits_max_assets_per_user: int = Field(default=1, ge=0) lnbits_max_assets_per_user: int = Field(default=1, ge=0)
lnbits_assets_no_limit_users: list[str] = Field(default=[]) lnbits_assets_no_limit_users: list[str] = Field(default=[])
def is_unlimited_assets_user(self, user_id: str) -> bool:
return (
settings.is_admin_user(user_id)
or user_id in self.lnbits_assets_no_limit_users
)
class FeeSettings(LNbitsSettings): class FeeSettings(LNbitsSettings):
lnbits_reserve_fee_min: int = Field(default=2000, ge=0) lnbits_reserve_fee_min: int = Field(default=2000, ge=0)
@@ -631,6 +600,7 @@ class BreezLiquidSdkFundingSource(LNbitsSettings):
class BoltzFundingSource(LNbitsSettings): class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002") boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
boltz_client_macaroon: str | None = Field(default=None) boltz_client_macaroon: str | None = Field(default=None)
boltz_client_wallet: str | None = Field(default="lnbits")
boltz_client_password: str = Field(default="") boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None) boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None) boltz_mnemonic: str | None = Field(default=None)
@@ -671,20 +641,6 @@ class StripeFiatProvider(LNbitsSettings):
stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits) stripe_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class PayPalFiatProvider(LNbitsSettings):
paypal_enabled: bool = Field(default=False)
paypal_api_endpoint: str = Field(default="https://api-m.paypal.com")
paypal_client_id: str | None = Field(default=None)
paypal_client_secret: str | None = Field(default=None)
paypal_payment_success_url: str = Field(default="https://lnbits.com")
paypal_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/paypal"
)
paypal_webhook_id: str | None = Field(default=None)
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0) lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -720,7 +676,7 @@ class FundingSourcesSettings(
funding_source_max_retries: int = Field(default=4, ge=0) funding_source_max_retries: int = Field(default=4, ge=0)
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider): class FiatProvidersSettings(StripeFiatProvider):
def is_fiat_provider_enabled(self, provider: str | None) -> bool: def is_fiat_provider_enabled(self, provider: str | None) -> bool:
""" """
Checks if a specific fiat provider is enabled. Checks if a specific fiat provider is enabled.
@@ -729,8 +685,7 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
return False return False
if provider == "stripe": if provider == "stripe":
return self.stripe_enabled return self.stripe_enabled
if provider == "paypal": # Add checks for other fiat providers here as needed
return self.paypal_enabled
return False return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]: def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
@@ -744,12 +699,7 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
): ):
allowed_providers.append("stripe") allowed_providers.append("stripe")
if self.paypal_enabled and ( # Add other fiat providers here as needed
not self.paypal_limits.allowed_users
or user_id in self.paypal_limits.allowed_users
):
allowed_providers.append("paypal")
return allowed_providers return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None: def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
@@ -806,7 +756,6 @@ class AuthSettings(LNbitsSettings):
# How many seconds after login the user is allowed to update its credentials. # How many seconds after login the user is allowed to update its credentials.
# A fresh login is required afterwards. # A fresh login is required afterwards.
auth_credetials_update_threshold: int = Field(default=120, gt=0) auth_credetials_update_threshold: int = Field(default=120, gt=0)
auth_authentication_cache_minutes: int = Field(default=10, ge=0)
def is_auth_method_allowed(self, method: AuthMethods): def is_auth_method_allowed(self, method: AuthMethods):
return method.value in self.auth_allowed_methods return method.value in self.auth_allowed_methods
@@ -991,7 +940,6 @@ class EnvSettings(LNbitsSettings):
enable_log_to_file: bool = Field(default=True) enable_log_to_file: bool = Field(default=True)
log_rotation: str = Field(default="100 MB") log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months") log_retention: str = Field(default="3 months")
first_install_token: str | None = Field(default=None)
cleanup_wallets_days: int = Field(default=90, ge=0) cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0) funding_source_max_retries: int = Field(default=4, ge=0)
@@ -1140,123 +1088,6 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
and ext_id in self.lnbits_all_extensions_ids and ext_id in self.lnbits_all_extensions_ids
) )
def to_public(self) -> PublicSettings:
return PublicSettings.from_settings(self)
class PublicSettings(BaseModel):
"""
PublicSettings is used for templating and public information.
WARNING: do not expose private information, PublicSettings is exposed publicly.
"""
allow_register: bool = Field(alias="allowRegister")
qr_logo: str = Field(alias="qrLogo")
site_title: str = Field(alias="siteTitle")
site_tagline: str = Field(alias="siteTagline")
site_description: str | None = Field(alias="siteDescription")
auth_methods: list[str] = Field(alias="authMethods")
keycloak_org: str | None = Field(alias="keycloakOrg")
keycloak_icon: str | None = Field(alias="keycloakIcon")
has_holdinvoice: bool = Field(alias="hasHoldinvoice")
has_nodemanager: bool = Field(alias="hasNodemanager")
show_nodemanager: bool = Field(alias="showNodemanager")
custom_logo: str | None = Field(alias="customLogo")
show_voidwallet: bool = Field(alias="showVoidwallet")
version: str = Field(alias="version")
show_home_page_elements: bool = Field(alias="showHomePageElements")
hide_api: bool = Field(alias="hideApi")
cache_key: str = Field(alias="cacheKey")
webpush_pubkey: str | None = Field(alias="webpushPubkey")
show_extensions: bool = Field(alias="showExtensions")
show_audit: bool = Field(alias="showAudit")
show_admin: bool = Field(alias="showAdmin")
ad_space: list[list[str]] = Field(alias="adSpace")
ad_space_title: str = Field(alias="adSpaceTitle")
show_ad_space: bool = Field(alias="showAdSpace")
custom_image: str | None = Field(alias="customImage")
custom_badge: str | None = Field(alias="customBadge")
custom_badge_color: str | None = Field(alias="customBadgeColor")
service_fee: float = Field(alias="serviceFee")
service_fee_max: int = Field(alias="serviceFeeMax")
service_fee_wallet: str | None = Field(alias="serviceFeeWallet")
theme_options: list[str] = Field(alias="themeOptions")
default_reaction: str = Field(alias="defaultReaction")
default_theme: str = Field(alias="defaultTheme")
default_border: str = Field(alias="defaultBorder")
default_bgimage: str | None = Field(alias="defaultBgimage")
default_gradient: bool = Field(alias="defaultGradient")
denomination: str | None = Field()
extensions: list[str] = Field()
allowed_currencies: list[str] = Field(alias="allowedCurrencies")
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
ext_builder: bool = Field(alias="extBuilder")
nostr_configured: bool = Field(alias="nostrConfigured")
telegram_configured: bool = Field(alias="telegramConfigured")
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
wallet_featured_button_icon: str | None = Field(alias="walletFeaturedButtonIcon")
lnbits_user_activation_by_email: bool = Field(alias="userActivationByEmail")
lnbits_user_activation_by_payment: bool = Field(alias="userActivationByPayment")
lnbits_user_activation_by_invitation_code: bool = Field(
alias="userActivationByInvitationCode"
)
@classmethod
def from_settings(cls, settings: Settings):
ads = [x.split(";") for x in settings.lnbits_ad_space.split(",")]
return cls(
adSpace=ads,
adSpaceTitle=settings.lnbits_ad_space_title,
showAdSpace=settings.lnbits_ad_space_enabled and len(ads) > 0,
allowRegister=settings.new_accounts_allowed,
qrLogo=settings.lnbits_qr_logo,
siteDescription=settings.lnbits_site_description,
siteTitle=settings.lnbits_site_title,
siteTagline=settings.lnbits_site_tagline,
authMethods=settings.auth_allowed_methods,
keycloakOrg=settings.keycloak_client_custom_org,
keycloakIcon=settings.keycloak_client_custom_icon,
hasHoldinvoice=settings.has_holdinvoice,
hasNodemanager=settings.has_nodemanager,
showNodemanager=settings.lnbits_node_ui and settings.has_nodemanager,
customLogo=settings.lnbits_custom_logo,
showVoidwallet=settings.lnbits_backend_wallet_class == "VoidWallet",
version=settings.version,
showHomePageElements=settings.lnbits_show_home_page_elements,
hideApi=settings.lnbits_hide_api,
cacheKey=str(settings.server_startup_time),
webpushPubkey=settings.lnbits_webpush_pubkey,
showExtensions=not settings.lnbits_extensions_deactivate_all,
showAudit=settings.lnbits_audit_enabled,
showAdmin=settings.lnbits_admin_ui,
customImage=settings.lnbits_custom_image,
customBadge=settings.lnbits_custom_badge,
customBadgeColor=settings.lnbits_custom_badge_color,
serviceFee=settings.lnbits_service_fee,
serviceFeeMax=settings.lnbits_service_fee_max,
serviceFeeWallet=settings.lnbits_service_fee_wallet,
themeOptions=settings.lnbits_theme_options,
defaultReaction=settings.lnbits_default_reaction,
defaultTheme=settings.lnbits_default_theme,
defaultBorder=settings.lnbits_default_border,
defaultGradient=settings.lnbits_default_gradient,
defaultBgimage=settings.lnbits_default_bgimage,
denomination=settings.lnbits_denomination,
extensions=list(settings.lnbits_installed_extensions_ids),
allowedCurrencies=settings.lnbits_allowed_currencies,
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
nostrConfigured=settings.is_nostr_notifications_configured(),
telegramConfigured=settings.is_telegram_notifications_configured(),
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
walletFeaturedButtonIcon=settings.lnbits_wallet_featured_button_icon,
userActivationByEmail=settings.lnbits_user_activation_by_email,
userActivationByPayment=settings.lnbits_user_activation_by_payment,
userActivationByInvitationCode=settings.lnbits_user_activation_by_invitation_code,
)
class SuperSettings(EditableSettings): class SuperSettings(EditableSettings):
super_user: str super_user: str
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
+17 -17
View File
File diff suppressed because one or more lines are too long
+1 -10
View File
@@ -217,7 +217,7 @@ body.body--dark .q-header,
body.body--dark .q-drawer { body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3); --q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark); background-color: var(--q-dark);
backdrop-filter: blur(6px) brightness(0.8); backdrop-filter: blur(6px);
} }
:root { :root {
@@ -455,12 +455,3 @@ video {
width: 90% !important; width: 90% !important;
} }
} }
.error-code {
font-size: clamp(15vh, 20vw, 30vh);
}
.error-message {
font-size: clamp(1.5rem, 3vw, 3.75rem);
font-weight: 300;
opacity: 0.4;
}
+1 -70
View File
@@ -74,8 +74,6 @@ window.localisation.en = {
update_name: 'Update name', update_name: 'Update name',
fiat_tracking: 'Fiat tracking', fiat_tracking: 'Fiat tracking',
fiat_providers: 'Fiat providers', fiat_providers: 'Fiat providers',
fiat_warning_bitcoin:
'Fiat providers can get twitchy about anything bitcoin, so avoid using word "bitcoin" in your memos!',
currency: 'Currency', currency: 'Currency',
update_currency: 'Update currency', update_currency: 'Update currency',
press_to_claim: 'Press to claim bitcoin', press_to_claim: 'Press to claim bitcoin',
@@ -171,7 +169,6 @@ window.localisation.en = {
drop_db: 'Remove Data', drop_db: 'Remove Data',
enable: 'Enable', enable: 'Enable',
enabled: 'Enabled', enabled: 'Enabled',
disabled: 'Disabled',
pay_to_enable: 'Pay To Enable', pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user', enable_extension_details: 'Enable extension for current user',
disable: 'Disable', disable: 'Disable',
@@ -179,8 +176,6 @@ window.localisation.en = {
installed: 'Installed', installed: 'Installed',
activated: 'Activated', activated: 'Activated',
deactivated: 'Deactivated', deactivated: 'Deactivated',
activate: 'Activate',
deactivate: 'Deactivate',
release_notes: 'Release Notes', release_notes: 'Release Notes',
activate_extension_details: 'Make extension available/unavailable for users', activate_extension_details: 'Make extension available/unavailable for users',
featured: 'Featured', featured: 'Featured',
@@ -189,28 +184,7 @@ window.localisation.en = {
only_admins_can_create_extensions: only_admins_can_create_extensions:
'Only admin accounts can create extensions', 'Only admin accounts can create extensions',
admin_only: 'Admin Only', admin_only: 'Admin Only',
make_user_admin: 'Make User Admin',
revoke_admin: 'Revoke Admin',
new_version: 'New Version', new_version: 'New Version',
reviews_url: 'Reviews URL',
reviews_url_label: 'Reviews server URL',
reviews_url_hint:
'Full PaidReviews URL including the settings id (e.g. https://example.com/paidreviews/SETTINGS_ID)',
reviews_open: 'View reviews',
reviews_leave: 'Leave a review',
reviews_name: 'Your name',
reviews_comment: 'Your review',
reviews_rating: 'Rating',
reviews_submit: 'Submit review',
reviews_loading: 'Loading reviews...',
reviews_refresh: 'Refresh reviews',
reviews_error_load: 'Could not load reviews',
reviews_url_not_configured: 'Reviews URL not configured',
reviews_pay_invoice: 'Pay invoice',
reviews_invoice_paid: 'Invoice paid',
reviews_invoice_title: 'Pay this invoice to submit your review',
reviews_count: 'Reviews',
no_reviews: 'No reviews yet',
extension_has_free_release: 'Has free releases', extension_has_free_release: 'Has free releases',
extension_has_paid_release: 'Has paid releases', extension_has_paid_release: 'Has paid releases',
extension_depends_on: 'Depends on:', extension_depends_on: 'Depends on:',
@@ -263,7 +237,6 @@ window.localisation.en = {
webhook_url: 'Webhook URL', webhook_url: 'Webhook URL',
webhook_url_hint: webhook_url_hint:
'Webhook URL to send the payment details to. It will be called when the payment is completed.', 'Webhook URL to send the payment details to. It will be called when the payment is completed.',
copy_webhook_url: 'Copy webhook URL',
webhook_events_list: 'The following events must be supported by the webhook:', webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description: webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.', 'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
@@ -285,7 +258,7 @@ window.localisation.en = {
'Nip5 identifier to send notifications to', 'Nip5 identifier to send notifications to',
notifications_nostr_identifiers: 'Nostr Identifiers', notifications_nostr_identifiers: 'Nostr Identifiers',
notifications_nostr_identifiers_desc: notifications_nostr_identifiers_desc:
'List of identifiers to send notifications to.', 'List of identifiers to send notifications to',
notifications_telegram_config: 'Telegram Configuration', notifications_telegram_config: 'Telegram Configuration',
notifications_enable_telegram: 'Enable Telegram', notifications_enable_telegram: 'Enable Telegram',
@@ -455,7 +428,6 @@ window.localisation.en = {
look_and_feel: 'Look and Feel', look_and_feel: 'Look and Feel',
endpoint: 'Endpoint', endpoint: 'Endpoint',
api: 'API', api: 'API',
api_stripe: 'API',
api_token: 'API Token', api_token: 'API Token',
api_tokens: 'API Tokens', api_tokens: 'API Tokens',
access_control_list: 'Access Control List', access_control_list: 'Access Control List',
@@ -523,7 +495,6 @@ window.localisation.en = {
hash: 'Hash: ', hash: 'Hash: ',
welcome_lnbits: 'Welcome to LNbits', welcome_lnbits: 'Welcome to LNbits',
setup_su_account: 'Set up the Superuser account below.', setup_su_account: 'Set up the Superuser account below.',
first_install_token: 'First Install Token (optional)',
create_ticker_converter: 'Create Currency Ticker Converter', create_ticker_converter: 'Create Currency Ticker Converter',
enable_audit: 'Enable Audit', enable_audit: 'Enable Audit',
recommended: 'Recommended', recommended: 'Recommended',
@@ -603,9 +574,6 @@ window.localisation.en = {
authentication: 'Authentication', authentication: 'Authentication',
auth_token_expiry_label: 'Token expire minutes', auth_token_expiry_label: 'Token expire minutes',
auth_token_expiry_hint: 'Time in minutes until the token expires', auth_token_expiry_hint: 'Time in minutes until the token expires',
auth_authentication_cache_label: 'Cache time (minutes)',
auth_authentication_cache_hint:
'Time in minutes to cache successful authentication (0 to disable)',
auth_allowed_methods_label: 'Allowed authorization methods', auth_allowed_methods_label: 'Allowed authorization methods',
auth_allowed_methods_hint: 'Select authorization methods', auth_allowed_methods_hint: 'Select authorization methods',
auth_nostr_label: 'Nostr Request URL', auth_nostr_label: 'Nostr Request URL',
@@ -664,23 +632,12 @@ window.localisation.en = {
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML', ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
ui_default_wallet_name: 'Default Wallet Name', ui_default_wallet_name: 'Default Wallet Name',
ui_default_theme: 'Default Theme', ui_default_theme: 'Default Theme',
wallet_featured_button_label: 'Wallet Featured Button Label',
wallet_featured_button_label_hint:
'Show featured button on the wallet homepage',
wallet_featured_button_url: 'Featured Button URL',
wallet_featured_button_url_hint:
'On click the button will open this URL. Leave empty to hide the button.',
wallet_featured_button_icon: 'Featured Button Icon',
wallet_featured_button_icon_hint:
'Icon shown on the featured button (check quasar icons)',
lnbits_wallet: 'LNbits wallet', lnbits_wallet: 'LNbits wallet',
denomination: 'Denomination', denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token', denomination_hint: 'The name for the FakeWallet token',
denomination_error: 'Denomination must be 3 characters, or `sats`', denomination_error: 'Denomination must be 3 characters, or `sats`',
ui_qr_code_logo: 'QR Code/Favicon Logo', ui_qr_code_logo: 'QR Code/Favicon Logo',
ui_qr_code_logo_hint: 'QR code and favicon logo url', ui_qr_code_logo_hint: 'QR code and favicon logo url',
ui_apple_touch_icon: 'Apple Touch Icon',
ui_apple_touch_icon_hint: 'Apple touch icon url',
ui_custom_image: 'Custom Image', ui_custom_image: 'Custom Image',
ui_custom_image_label: 'URL to custom image', ui_custom_image_label: 'URL to custom image',
ui_custom_image_hint: 'Image showed at homepage/login', ui_custom_image_hint: 'Image showed at homepage/login',
@@ -709,28 +666,7 @@ window.localisation.en = {
allowed_users_label: 'User ID', allowed_users_label: 'User ID',
allow_creation_user: 'Allow creation of new users', allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page', allow_creation_user_desc: 'Allow creation of new users on the index page',
require_user_activation: 'Require user activation',
require_user_activation_desc:
'New users will be activated only after they pass one of the confirmation methods. Admins can activate users manually from the admin panel.',
reusable_activation_code: 'Reusable activation code',
reusable_activation_code_label: 'Reusable activation code',
reusable_activation_code_hint:
'This activation code can be used multiple times by different users.',
one_time_activation_code: 'One-time activation codes',
one_time_activation_code_label: 'Add activation code',
one_time_activation_code_hint:
'List of one-time activation codes. Each code can be used only once, then will be reomved from the list.',
invitation_code: 'Invitation Code',
invitation_code_hint: 'The invitation code that you have received.',
email: 'Email',
email_confirmation_hint: 'Email address to send the confirmation code to.',
nostr_identifier: 'Nostr Identifier',
nostr_identifier_hint:
'Nostr nip5 identifier or <npub> to send the confirmation code to.',
new_user_not_allowed: 'Registration is disabled.', new_user_not_allowed: 'Registration is disabled.',
start_user_impersonation: 'Impersonate this user',
stop_user_impersonation: 'Stop User Impersonation',
components: 'Components', components: 'Components',
long_running_endpoints: 'Top 5 Long Running Endpoints', long_running_endpoints: 'Top 5 Long Running Endpoints',
http_request_methods: 'HTTP Request Methods', http_request_methods: 'HTTP Request Methods',
@@ -763,15 +699,10 @@ window.localisation.en = {
filter_labels: 'Filter labels', filter_labels: 'Filter labels',
filter_date: 'Filter by date', filter_date: 'Filter by date',
websocket_example: 'Websocket example', websocket_example: 'Websocket example',
client_id: 'Client ID',
secret_key: 'Secret Key', secret_key: 'Secret Key',
signing_secret: 'Signing Secret', signing_secret: 'Signing Secret',
signing_secret_hint: signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.', 'Signing secret for the webhook. Messages will be signed with this secret.',
webhook_id: 'Webhook ID',
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL', callback_success_url: 'Callback Success URL',
callback_success_url_hint: callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful', 'The user will be redirected to this URL after the payment is successful',
-5
View File
@@ -628,15 +628,10 @@ window.localisation.fi = {
filter_payments: 'Suodata maksuja', filter_payments: 'Suodata maksuja',
filter_date: 'Suodata päiväyksellä', filter_date: 'Suodata päiväyksellä',
websocket_example: 'Websocket example', websocket_example: 'Websocket example',
client_id: 'Client ID',
secret_key: 'Secret Key', secret_key: 'Secret Key',
signing_secret: 'Signing Secret', signing_secret: 'Signing Secret',
signing_secret_hint: signing_secret_hint:
'Signing secret for the webhook. Messages will be signed with this secret.', 'Signing secret for the webhook. Messages will be signed with this secret.',
webhook_id: 'Webhook ID',
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.',
callback_success_url: 'Callback Success URL', callback_success_url: 'Callback Success URL',
callback_success_url_hint: callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful' 'The user will be redirected to this URL after the payment is successful'
+12 -34
View File
@@ -66,7 +66,7 @@ window._lnbitsApi = {
name: name name: name
}) })
}, },
register(username, email, password, password_repeat, invitation_code) { register(username, email, password, password_repeat) {
return axios({ return axios({
method: 'POST', method: 'POST',
url: '/api/v1/auth/register', url: '/api/v1/auth/register',
@@ -74,8 +74,7 @@ window._lnbitsApi = {
username, username,
email, email,
password, password,
password_repeat, password_repeat
invitation_code
} }
}) })
}, },
@@ -90,12 +89,6 @@ window._lnbitsApi = {
} }
}) })
}, },
getAuthUser() {
return axios({
method: 'GET',
url: '/api/v1/auth'
})
},
login(username, password) { login(username, password) {
return axios({ return axios({
method: 'POST', method: 'POST',
@@ -124,30 +117,19 @@ window._lnbitsApi = {
url: '/api/v1/auth/logout' url: '/api/v1/auth/logout'
}) })
}, },
impersonateUser(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/impersonate',
data: {usr}
})
},
stopImpersonation() {
return axios({
method: 'DELETE',
url: '/api/v1/auth/impersonate'
})
},
getAuthenticatedUser() { getAuthenticatedUser() {
return this.request('get', '/api/v1/auth') return this.request('get', '/api/v1/auth')
}, },
getWallet(wallet) { getWallet(wallet) {
return this.request('get', '/api/v1/wallet', wallet.inkey) return this.request('get', '/api/v1/wallet', wallet.inkey)
}, },
createWallet(name, walletType, opts = {}) { createWallet(wallet, name, walletType, ops = {}) {
return this.request('post', '/api/v1/wallet', null, { return this.request('post', '/api/v1/wallet', wallet.adminkey, {
name: name, name: name,
wallet_type: walletType, wallet_type: walletType,
...opts ...ops
}).then(res => {
window.location = '/wallet?wal=' + res.data.id
}) })
}, },
updateWallet(name, wallet) { updateWallet(name, wallet) {
@@ -155,9 +137,6 @@ window._lnbitsApi = {
name: name name: name
}) })
}, },
updateUiCustomization(data = {}) {
return this.request('patch', '/api/v1/auth/ui', null, data)
},
resetWalletKeys(wallet) { resetWalletKeys(wallet) {
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then( return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
res => { res => {
@@ -166,7 +145,11 @@ window._lnbitsApi = {
) )
}, },
deleteWallet(wallet) { deleteWallet(wallet) {
return this.request('delete', `/api/v1/wallet/${wallet.id}`) return this.request('delete', `/api/v1/wallet/${wallet.id}`).then(_ => {
let url = new URL(window.location.href)
url.searchParams.delete('wal')
window.location = url
})
}, },
getPayments(wallet, params) { getPayments(wallet, params) {
return this.request( return this.request(
@@ -188,10 +171,5 @@ window._lnbitsApi = {
return this.request('GET', '/api/v1/currencies').then(response => { return this.request('GET', '/api/v1/currencies').then(response => {
return ['sats', ...response.data] return ['sats', ...response.data]
}) })
},
getDefaultSetting(fieldName) {
return LNbits.api
.request('GET', `/admin/api/v1/settings/default?field_name=${fieldName}`)
.catch(LNbits.utils.notifyApiError)
} }
} }
+4 -4
View File
@@ -6,16 +6,13 @@ window.LNbits = {
user(data) { user(data) {
const obj = { const obj = {
id: data.id, id: data.id,
username: data.username,
admin: data.admin, admin: data.admin,
email: data.email, email: data.email,
extensions: data.extensions, extensions: data.extensions,
wallets: data.wallets, wallets: data.wallets,
fiat_providers: data.fiat_providers || [], fiat_providers: data.fiat_providers || [],
super_user: data.super_user, super_user: data.super_user,
extra: data.extra ?? {}, extra: data.extra ?? {}
hasPassword: data.has_password ?? false,
uiCustomization: data.ui_customization || {}
} }
const mapWallet = this.wallet const mapWallet = this.wallet
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => { obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
@@ -53,6 +50,9 @@ window.LNbits = {
} }
newWallet.msat = data.balance_msat newWallet.msat = data.balance_msat
newWallet.sat = Math.floor(data.balance_msat / 1000) newWallet.sat = Math.floor(data.balance_msat / 1000)
newWallet.fsat = new Intl.NumberFormat(window.i18n.global.locale).format(
newWallet.sat
)
if (newWallet.walletType === 'lightning-shared') { if (newWallet.walletType === 'lightning-shared') {
const perms = newWallet.sharePermissions const perms = newWallet.sharePermissions
newWallet.canReceivePayments = perms.includes('receive-payments') newWallet.canReceivePayments = perms.includes('receive-payments')
+72 -53
View File
@@ -3,38 +3,44 @@ window.app.component(QrcodeVue)
window.app.component('lnbits-extension-rating', { window.app.component('lnbits-extension-rating', {
template: '#lnbits-extension-rating', template: '#lnbits-extension-rating',
name: 'lnbits-extension-rating', name: 'lnbits-extension-rating',
props: ['rating']
})
window.app.component('lnbits-fsat', {
template: '<span>{{ fsat }}</span>',
props: { props: {
rating: { amount: {
type: Number, type: Number,
default: 0 default: 0
},
count: {
type: Number,
default: null
},
clickable: {
type: Boolean,
default: false
} }
}, },
computed: { computed: {
displayRating() { fsat() {
return Math.round((this.rating || 0) * 2) / 2 return LNbits.utils.formatSat(this.amount)
},
hasData() {
return this.count !== null && this.count !== undefined
}
},
methods: {
handleClick() {
if (!this.clickable) return
this.$emit('click')
} }
} }
}) })
window.app.component('lnbits-manage', { window.app.component('lnbits-manage', {
mixins: [window.windowMixin],
template: '#lnbits-manage', template: '#lnbits-manage',
computed: {
showAdmin() {
return this.LNBITS_ADMIN_UI
},
showUsers() {
return this.LNBITS_ADMIN_UI
},
showNode() {
return this.LNBITS_NODE_UI
},
showAudit() {
return this.LNBITS_AUDIT_ENABLED
},
showExtensions() {
return this.LNBITS_EXTENSIONS_DEACTIVATE_ALL === false
}
},
methods: { methods: {
isActive(path) { isActive(path) {
return window.location.pathname === path return window.location.pathname === path
@@ -48,8 +54,15 @@ window.app.component('lnbits-manage', {
}) })
window.app.component('lnbits-payment-details', { window.app.component('lnbits-payment-details', {
mixins: [window.windowMixin],
template: '#lnbits-payment-details', template: '#lnbits-payment-details',
props: ['payment'], props: ['payment'],
mixins: [window.windowMixin],
data() {
return {
LNBITS_DENOMINATION: LNBITS_DENOMINATION
}
},
computed: { computed: {
hasPreimage() { hasPreimage() {
return ( return (
@@ -93,6 +106,7 @@ window.app.component('lnbits-payment-details', {
}) })
window.app.component('lnbits-lnurlpay-success-action', { window.app.component('lnbits-lnurlpay-success-action', {
mixins: [window.windowMixin],
template: '#lnbits-lnurlpay-success-action', template: '#lnbits-lnurlpay-success-action',
props: ['payment', 'success_action'], props: ['payment', 'success_action'],
data() { data() {
@@ -102,16 +116,17 @@ window.app.component('lnbits-lnurlpay-success-action', {
}, },
mounted() { mounted() {
if (this.success_action.tag !== 'aes') return null if (this.success_action.tag !== 'aes') return null
this.utils decryptLnurlPayAES(this.success_action, this.payment.preimage).then(
.decryptLnurlPayAES(this.success_action, this.payment.preimage) value => {
.then(value => {
this.decryptedValue = value this.decryptedValue = value
}) }
)
} }
}) })
window.app.component('lnbits-notifications-btn', { window.app.component('lnbits-notifications-btn', {
template: '#lnbits-notifications-btn', template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
props: ['pubkey'], props: ['pubkey'],
data() { data() {
return { return {
@@ -282,6 +297,7 @@ window.app.component('lnbits-notifications-btn', {
window.app.component('lnbits-dynamic-fields', { window.app.component('lnbits-dynamic-fields', {
template: '#lnbits-dynamic-fields', template: '#lnbits-dynamic-fields',
mixins: [window.windowMixin],
props: ['options', 'modelValue'], props: ['options', 'modelValue'],
data() { data() {
return { return {
@@ -314,6 +330,7 @@ window.app.component('lnbits-dynamic-fields', {
window.app.component('lnbits-dynamic-chips', { window.app.component('lnbits-dynamic-chips', {
template: '#lnbits-dynamic-chips', template: '#lnbits-dynamic-chips',
mixins: [window.windowMixin],
props: ['modelValue'], props: ['modelValue'],
data() { data() {
return { return {
@@ -344,10 +361,14 @@ window.app.component('lnbits-dynamic-chips', {
window.app.component('lnbits-update-balance', { window.app.component('lnbits-update-balance', {
template: '#lnbits-update-balance', template: '#lnbits-update-balance',
mixins: [window.windowMixin],
props: ['wallet_id', 'small_btn'], props: ['wallet_id', 'small_btn'],
computed: { computed: {
denomination() {
return LNBITS_DENOMINATION
},
admin() { admin() {
return this.g.user?.super_user === true return user.super_user
} }
}, },
data() { data() {
@@ -382,6 +403,7 @@ window.app.component('lnbits-update-balance', {
window.app.component('user-id-only', { window.app.component('user-id-only', {
template: '#user-id-only', template: '#user-id-only',
mixins: [window.windowMixin],
props: { props: {
allowed_new_users: Boolean, allowed_new_users: Boolean,
authAction: String, authAction: String,
@@ -425,6 +447,7 @@ window.app.component('user-id-only', {
window.app.component('username-password', { window.app.component('username-password', {
template: '#username-password', template: '#username-password',
mixins: [window.windowMixin],
props: { props: {
allowed_new_users: Boolean, allowed_new_users: Boolean,
authMethods: Array, authMethods: Array,
@@ -432,10 +455,8 @@ window.app.component('username-password', {
username: String, username: String,
password_1: String, password_1: String,
password_2: String, password_2: String,
invitationCode: String,
resetKey: String resetKey: String
}, },
data() { data() {
return { return {
oauth: [ oauth: [
@@ -448,10 +469,8 @@ window.app.component('username-password', {
password: this.password_1, password: this.password_1,
passwordRepeat: this.password_2, passwordRepeat: this.password_2,
reset_key: this.resetKey, reset_key: this.resetKey,
confirmationMethod: 'code', keycloakOrg: LNBITS_AUTH_KEYCLOAK_ORG || 'Keycloak',
confirmationEmail: '', keycloakIcon: LNBITS_AUTH_KEYCLOAK_ICON
confirmationCode: this.invitationCode || '',
showConfirmationCode: false
} }
}, },
methods: { methods: {
@@ -464,7 +483,6 @@ window.app.component('username-password', {
this.$emit('update:userName', this.username) this.$emit('update:userName', this.username)
this.$emit('update:password_1', this.password) this.$emit('update:password_1', this.password)
this.$emit('update:password_2', this.passwordRepeat) this.$emit('update:password_2', this.passwordRepeat)
this.$emit('update:invitationCode', this.confirmationCode)
this.$emit('register') this.$emit('register')
}, },
reset() { reset() {
@@ -556,25 +574,6 @@ window.app.component('username-password', {
computed: { computed: {
showOauth() { showOauth() {
return this.oauth.some(m => this.authMethods.includes(m)) return this.oauth.some(m => this.authMethods.includes(m))
},
disableRegister() {
const usernameOK = !!this.username
const passwordOK = !!this.password && this.password.length >= 8
const passwordsMatch = this.password === this.passwordRepeat
const codeOk =
this.confirmationMethodsCount === 0 ||
this.confirmationMethod !== 'code' ||
this.confirmationCode.length > 0
return !usernameOK || !passwordOK || !passwordsMatch || !codeOk
},
confirmationMethodsCount() {
const methods = [
this.g.settings.userActivationByEmail,
this.g.settings.userActivationByPayment,
this.g.settings.userActivationByInvitationCode
]
return methods.filter(Boolean).length
} }
}, },
created() {} created() {}
@@ -686,6 +685,7 @@ window.app.component('lnbits-stat', {
window.app.component('lnbits-node-qrcode', { window.app.component('lnbits-node-qrcode', {
props: ['info'], props: ['info'],
mixins: [window.windowMixin],
template: ` template: `
<q-card class="my-card"> <q-card class="my-card">
<q-card-section> <q-card-section>
@@ -708,7 +708,7 @@ window.app.component('lnbits-node-qrcode', {
dense dense
unelevated unelevated
size="md" size="md"
@click="utils.copyText(info.id)" @click="copyText(info.id)"
>Public Key<q-tooltip> Click to copy </q-tooltip> >Public Key<q-tooltip> Click to copy </q-tooltip>
</q-btn> </q-btn>
</q-card-actions> </q-card-actions>
@@ -757,6 +757,24 @@ window.app.component('lnbits-channel-balance', {
` `
}) })
window.app.component('lnbits-date', {
props: ['ts'],
computed: {
date() {
return LNbits.utils.formatDate(this.ts)
},
dateFrom() {
return moment.utc(this.date).local().fromNow()
}
},
template: `
<div>
<q-tooltip>{{ this.date }}</q-tooltip>
{{ this.dateFrom }}
</div>
`
})
window.app.component('lnbits-node-info', { window.app.component('lnbits-node-info', {
props: ['info'], props: ['info'],
data() { data() {
@@ -764,6 +782,7 @@ window.app.component('lnbits-node-info', {
showDialog: false showDialog: false
} }
}, },
mixins: [window.windowMixin],
methods: { methods: {
shortenNodeId(nodeId) { shortenNodeId(nodeId) {
return nodeId return nodeId
@@ -788,7 +807,7 @@ window.app.component('lnbits-node-info', {
flat flat
dense dense
icon='content_paste' icon='content_paste'
@click='utils.copyText(info.id)' @click='copyText(info.id)'
></q-btn> ></q-btn>
<q-btn <q-btn
size='xs' size='xs'
@@ -1,6 +1,7 @@
window.app.component('lnbits-admin-assets-config', { window.app.component('lnbits-admin-assets-config', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-assets-config', template: '#lnbits-admin-assets-config',
mixins: [window.windowMixin],
data() { data() {
return { return {
newAllowedAssetMimeType: '', newAllowedAssetMimeType: '',
@@ -16,7 +17,6 @@ window.app.component('lnbits-admin-assets-config', {
this.newAllowedAssetMimeType this.newAllowedAssetMimeType
) )
this.newAllowedAssetMimeType = '' this.newAllowedAssetMimeType = ''
this.formData.touch = null
} }
}, },
removeAllowedAssetMimeType(type) { removeAllowedAssetMimeType(type) {
@@ -24,21 +24,18 @@ window.app.component('lnbits-admin-assets-config', {
if (index !== -1) { if (index !== -1) {
this.formData.lnbits_assets_allowed_mime_types.splice(index, 1) this.formData.lnbits_assets_allowed_mime_types.splice(index, 1)
} }
this.formData.touch = null
}, },
addNewNoLimitUser() { addNewNoLimitUser() {
if (this.newNoLimitUser) { if (this.newNoLimitUser) {
this.removeNoLimitUser(this.newNoLimitUser) this.removeNoLimitUser(this.newNoLimitUser)
this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser) this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser)
this.newNoLimitUser = '' this.newNoLimitUser = ''
this.formData.touch = null
} }
}, },
removeNoLimitUser(user) { removeNoLimitUser(user) {
if (user) { if (user) {
this.formData.lnbits_assets_no_limit_users = this.formData.lnbits_assets_no_limit_users =
this.formData.lnbits_assets_no_limit_users.filter(u => u !== user) this.formData.lnbits_assets_no_limit_users.filter(u => u !== user)
this.formData.touch = null
} }
} }
} }
@@ -1,6 +1,7 @@
window.app.component('lnbits-admin-audit', { window.app.component('lnbits-admin-audit', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-audit', template: '#lnbits-admin-audit',
mixins: [window.windowMixin],
data() { data() {
return { return {
formAddIncludePath: '', formAddIncludePath: '',
@@ -1,6 +1,7 @@
window.app.component('lnbits-admin-exchange-providers', { window.app.component('lnbits-admin-exchange-providers', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-exchange-providers', template: '#lnbits-admin-exchange-providers',
mixins: [window.windowMixin],
data() { data() {
return { return {
exchangeData: { exchangeData: {
@@ -69,11 +70,6 @@ window.app.component('lnbits-admin-exchange-providers', {
} }
}, },
methods: { methods: {
getDefaultSetting(fieldName) {
LNbits.api.getDefaultSetting(fieldName).then(response => {
this.formData[fieldName] = response.data.default_value
})
},
getExchangeRateHistory() { getExchangeRateHistory() {
LNbits.api LNbits.api
.request('GET', '/api/v1/rate/history', this.g.user.wallets[0].inkey) .request('GET', '/api/v1/rate/history', this.g.user.wallets[0].inkey)
@@ -128,7 +124,7 @@ window.app.component('lnbits-admin-exchange-providers', {
}, },
initExchangeChart(data) { initExchangeChart(data) {
const xValues = data.map(d => const xValues = data.map(d =>
this.utils.formatTimestamp(d.timestamp, 'HH:mm') Quasar.date.formatDate(new Date(d.timestamp * 1000), 'HH:mm')
) )
const exchanges = [ const exchanges = [
...this.formData.lnbits_exchange_rate_providers, ...this.formData.lnbits_exchange_rate_providers,
@@ -1,6 +1,7 @@
window.app.component('lnbits-admin-extensions', { window.app.component('lnbits-admin-extensions', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-extensions', template: '#lnbits-admin-extensions',
mixins: [window.windowMixin],
data() { data() {
return { return {
formAddExtensionsManifest: '' formAddExtensionsManifest: ''
@@ -1,82 +1,14 @@
window.app.component('lnbits-admin-fiat-providers', { window.app.component('lnbits-admin-fiat-providers', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-fiat-providers', template: '#lnbits-admin-fiat-providers',
mixins: [window.windowMixin],
data() { data() {
return { return {
formAddStripeUser: '', formAddStripeUser: '',
formAddPaypalUser: '',
hideInputToggle: true hideInputToggle: true
} }
}, },
computed: {
stripeWebhookUrl() {
return (
this.formData?.stripe_payment_webhook_url ||
this.calculateWebhookUrl('stripe')
)
},
paypalWebhookUrl() {
return (
this.formData?.paypal_payment_webhook_url ||
this.calculateWebhookUrl('paypal')
)
}
},
watch: {
formData: {
handler() {
this.syncWebhookUrls()
},
immediate: true
}
},
methods: { methods: {
basePathFromLocation() {
if (typeof window === 'undefined') {
return ''
}
const normalizedPath = window.location.pathname.replace(/\/+$/, '')
const adminIndex = normalizedPath.lastIndexOf('/admin')
const basePath =
adminIndex >= 0
? normalizedPath.slice(0, adminIndex)
: normalizedPath || ''
return basePath || ''
},
calculateWebhookUrl(provider) {
if (typeof window === 'undefined') {
return ''
}
const basePath = this.basePathFromLocation()
const path = `${basePath}/api/v1/callback/${provider}`.replace(
/\/+/g,
'/'
)
const withLeadingSlash = path.startsWith('/') ? path : `/${path}`
return `${window.location.origin}${withLeadingSlash}`
},
syncWebhookUrls() {
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
},
maybeSetWebhookUrl(fieldName, provider) {
if (!this.formData) {
return
}
const calculated = this.calculateWebhookUrl(provider)
const current = this.formData[fieldName]
const hasPlaceholder =
!current || current.includes('your-lnbits-domain-here.com')
if (hasPlaceholder && calculated) {
this.formData[fieldName] = calculated
}
},
copyWebhookUrl(url) {
if (!url) {
return
}
this.copyText(url)
},
addStripeAllowedUser() { addStripeAllowedUser() {
const addUser = this.formAddStripeUser || '' const addUser = this.formAddStripeUser || ''
if ( if (
@@ -94,23 +26,6 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.stripe_limits.allowed_users = this.formData.stripe_limits.allowed_users =
this.formData.stripe_limits.allowed_users.filter(u => u !== user) this.formData.stripe_limits.allowed_users.filter(u => u !== user)
}, },
addPaypalAllowedUser() {
const addUser = this.formAddPaypalUser || ''
if (
addUser.length &&
!this.formData.paypal_limits.allowed_users.includes(addUser)
) {
this.formData.paypal_limits.allowed_users = [
...this.formData.paypal_limits.allowed_users,
addUser
]
this.formAddPaypalUser = ''
}
},
removePaypalAllowedUser(user) {
this.formData.paypal_limits.allowed_users =
this.formData.paypal_limits.allowed_users.filter(u => u !== user)
},
checkFiatProvider(providerName) { checkFiatProvider(providerName) {
LNbits.api LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`) .request('PUT', `/api/v1/fiat/check/${providerName}`)
@@ -1,5 +1,6 @@
window.app.component('lnbits-admin-funding-sources', { window.app.component('lnbits-admin-funding-sources', {
template: '#lnbits-admin-funding-sources', template: '#lnbits-admin-funding-sources',
mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'], props: ['form-data', 'allowed-funding-sources'],
methods: { methods: {
getFundingSourceLabel(item) { getFundingSourceLabel(item) {
@@ -88,14 +89,8 @@ window.app.component('lnbits-admin-funding-sources', {
lnd_rest_cert: 'Certificate', lnd_rest_cert: 'Certificate',
lnd_rest_macaroon: 'Macaroon', lnd_rest_macaroon: 'Macaroon',
lnd_rest_macaroon_encrypted: 'Encrypted Macaroon', lnd_rest_macaroon_encrypted: 'Encrypted Macaroon',
lnd_rest_route_hints: { lnd_rest_route_hints: 'Enable Route Hints',
advanced: true, lnd_rest_allow_self_payment: 'Allow Self Payment'
label: 'Enable Route Hints'
},
lnd_rest_allow_self_payment: {
advanced: true,
label: 'Allow Self Payment'
}
} }
], ],
[ [
@@ -165,27 +160,16 @@ window.app.component('lnbits-admin-funding-sources', {
'BoltzWallet', 'BoltzWallet',
'Boltz', 'Boltz',
{ {
boltz_client_endpoint: { boltz_client_endpoint: 'Endpoint',
label: 'Boltz client endpoint', boltz_client_macaroon: 'Admin Macaroon path or hex',
value: '127.0.0.1:9002' boltz_client_cert: 'Certificate path or hex',
}, boltz_client_wallet: 'Wallet Name',
boltz_client_macaroon: { boltz_client_password: 'Wallet Password (can be empty)',
label: 'Admin Macaroon path or hex',
value: '/home/ubuntu/.boltz/macaroons/admin.macaroon'
},
boltz_client_cert: {
label: 'Certificate path or hex',
value: '/home/ubuntu/.boltz/tls.cert'
},
boltz_mnemonic: { boltz_mnemonic: {
label: 'Liquid seed phrase', label: 'Liquid mnemonic (copy into greenwallet)',
hint: 'Boltz will fetch once connected, but you can change later (can be opened in a liquid wallet) ', readonly: true,
copy: true, copy: true,
qrcode: true qrcode: true
},
boltz_client_password: {
label: 'Wallet Password (optional)',
advanced: true
} }
} }
], ],
@@ -1,6 +1,7 @@
window.app.component('lnbits-admin-funding', { window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'], props: ['is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding', template: '#lnbits-admin-funding',
mixins: [window.windowMixin],
data() { data() {
return { return {
auditData: [] auditData: []
@@ -1,6 +1,7 @@
window.app.component('lnbits-admin-notifications', { window.app.component('lnbits-admin-notifications', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-notifications', template: '#lnbits-admin-notifications',
mixins: [window.windowMixin],
data() { data() {
return { return {
nostrNotificationIdentifier: '', nostrNotificationIdentifier: '',
@@ -1,6 +1,7 @@
window.app.component('lnbits-admin-security', { window.app.component('lnbits-admin-security', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-security', template: '#lnbits-admin-security',
mixins: [window.windowMixin],
data() { data() {
return { return {
logs: [], logs: [],
@@ -1,4 +1,5 @@
window.app.component('lnbits-admin-server', { window.app.component('lnbits-admin-server', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-server' template: '#lnbits-admin-server',
mixins: [window.windowMixin]
}) })
@@ -1,6 +1,7 @@
window.app.component('lnbits-admin-site-customisation', { window.app.component('lnbits-admin-site-customisation', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-site-customisation', template: '#lnbits-admin-site-customisation',
mixins: [window.windowMixin],
data() { data() {
return { return {
lnbits_theme_options: [ lnbits_theme_options: [
@@ -1,12 +1,11 @@
window.app.component('lnbits-admin-users', { window.app.component('lnbits-admin-users', {
props: ['form-data'], props: ['form-data'],
template: '#lnbits-admin-users', template: '#lnbits-admin-users',
mixins: [window.windowMixin],
data() { data() {
return { return {
formAddUser: '', formAddUser: '',
formAddAdmin: '', formAddAdmin: ''
formAddActivationCode: '',
showReusableActivationCode: false
} }
}, },
methods: { methods: {
@@ -33,24 +32,6 @@ window.app.component('lnbits-admin-users', {
removeAdminUser(user) { removeAdminUser(user) {
let admin_users = this.formData.lnbits_admin_users let admin_users = this.formData.lnbits_admin_users
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user) this.formData.lnbits_admin_users = admin_users.filter(u => u !== user)
},
addOneTimeActivationCode() {
const code = this.formAddActivationCode
const activationCodes =
this.formData.lnbits_register_one_time_activation_codes
if (code?.length && !activationCodes.includes(code)) {
this.formData.lnbits_register_one_time_activation_codes = [
...activationCodes,
code
]
this.formAddActivationCode = ''
}
},
removeOneTimeActivationCode(code) {
const codes = this.formData.lnbits_register_one_time_activation_codes
this.formData.lnbits_register_one_time_activation_codes = codes.filter(
u => u !== code
)
} }
} }
}) })
@@ -1,5 +1,6 @@
window.app.component('lnbits-disclaimer', { window.app.component('lnbits-disclaimer', {
template: '#lnbits-disclaimer', template: '#lnbits-disclaimer',
mixins: [window.windowMixin],
computed: { computed: {
showDisclaimer() { showDisclaimer() {
return !g.disclaimerShown && g.isUserAuthorized return !g.disclaimerShown && g.isUserAuthorized
+2 -1
View File
@@ -1,3 +1,4 @@
window.app.component('lnbits-drawer', { window.app.component('lnbits-drawer', {
template: '#lnbits-drawer' template: '#lnbits-drawer',
mixins: [window.windowMixin]
}) })
@@ -1,52 +0,0 @@
window.app.component('lnbits-error', {
template: '#lnbits-error',
props: ['dynamic', 'code', 'message'],
computed: {
isExtension() {
if (this.code != 403) return false
if (this.message.startsWith('Extension ')) return true
}
},
methods: {
goBack() {
window.history.back()
},
goHome() {
window.location = '/'
},
goToWallet() {
if (this.dynamic) {
this.$router.push('/wallet')
return
}
window.location = '/wallet'
},
goToExtension() {
const extension = this.message.match(/'([^']+)'/)[1]
const url = `/extensions#${extension}`
if (this.dynamic) {
this.$router.push(url)
return
}
window.location = url
},
async logOut() {
try {
await LNbits.api.logout()
window.location = '/'
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
},
async created() {
// check if we have error from error.html
if (!this.dynamic) {
if (this.code == 401) {
console.warn(`Unauthorized: ${this.errorMessage}`)
this.logOut()
return
}
}
}
})
+8 -4
View File
@@ -1,11 +1,15 @@
window.app.component('lnbits-footer', { window.app.component('lnbits-footer', {
template: '#lnbits-footer', template: '#lnbits-footer',
mixins: [window.windowMixin],
computed: { computed: {
title() {
return `${this.g.settings.siteTitle}, ${this.g.settings.siteTagline}`
},
version() { version() {
return this.$t('lnbits_version') + ': ' + this.g.settings.version return this.LNBITS_VERSION || 'unknown version'
},
title() {
return `${this.SITE_TITLE}, ${this.SITE_TAGLINE}`
},
showFooter() {
return this.LNBITS_SHOW_HOME_PAGE_ELEMENTS == true
} }
} }
}) })
@@ -1,3 +1,4 @@
window.app.component('lnbits-header-wallets', { window.app.component('lnbits-header-wallets', {
template: '#lnbits-header-wallets' template: '#lnbits-header-wallets',
mixins: [window.windowMixin]
}) })
+48 -39
View File
@@ -1,48 +1,57 @@
window.app.component('lnbits-header', { window.app.component('lnbits-header', {
template: '#lnbits-header', template: '#lnbits-header',
mixins: [window.windowMixin],
computed: { computed: {
denomination() {
return this.LNBITS_DENOMINATION || 'sat'
},
hasServiceFeeMax() {
return (
this.g.user &&
this.LNBITS_SERVICE_FEE_MAX &&
this.LNBITS_SERVICE_FEE_MAX > 0
)
},
serviceFeeMax() {
return this.LNBITS_SERVICE_FEE_MAX || 0
},
hasServiceFee() {
return (
this.g.user && this.LNBITS_SERVICE_FEE && this.LNBITS_SERVICE_FEE > 0
)
},
serviceFee() {
return this.LNBITS_SERVICE_FEE || 0
},
hasCustomBadge() {
return this.LNBITS_CUSTOM_BADGE && this.LNBITS_CUSTOM_BADGE != ''
},
customBadge() {
return this.LNBITS_CUSTOM_BADGE || ''
},
customBadgeColor() {
return this.LNBITS_CUSTOM_BADGE_COLOR || ''
},
title() {
return this.SITE_TITLE
},
titleIsLnbits() {
return this.SITE_TITLE == 'LNbits'
},
customLogoUrl() {
return this.USE_CUSTOM_LOGO || null
},
userPictureUrl() {
return this.g.user.config.picture
},
hasUserPicture() {
return this.g.user && this.g.user.config && this.g.user.config.picture
},
showAdmin() { showAdmin() {
return this.g.user && (this.g.user.super_user || this.g.user.admin) return this.g.user && (this.g.user.super_user || this.g.user.admin)
}, },
displayName() { showVoidwallet() {
return ( return this.g.user && this.VOIDWALLET == true
this.g.user?.extra?.display_name ||
this.g.user.username ||
this.g.user?.extra?.first_name ||
'Anon'
)
},
displayRole() {
if (this.g.user?.super_user) {
return 'Super User'
} else if (this.g.user?.admin) {
return 'Admin'
} else {
return 'User'
}
}
},
methods: {
async stopImpersonation() {
try {
await LNbits.api.stopImpersonation()
LNbits.utils.restoreLocalStorage('impersonation')
window.location = '/users'
} catch (e) {
console.warn(e)
}
},
async handleLanguageChanged(lang) {
try {
await LNbits.api.updateUiCustomization({locale: lang.locale})
this.$q.notify({
type: 'positive',
message: 'Language Updated',
caption: lang.locale
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
} }
} }
}) })
@@ -1,5 +1,6 @@
window.app.component('lnbits-home-logos', { window.app.component('lnbits-home-logos', {
template: '#lnbits-home-logos', template: '#lnbits-home-logos',
mixins: [window.windowMixin],
data() { data() {
return { return {
logos: [ logos: [
@@ -94,9 +95,9 @@ window.app.component('lnbits-home-logos', {
computed: { computed: {
showLogos() { showLogos() {
return ( return (
this.g.isSatsDenomination && this.LNBITS_DENOMINATION == 'sats' &&
this.g.settings.siteTitle == 'LNbits' && this.SITE_TITLE == 'LNbits' &&
this.g.settings.showHomePageElements == true this.LNBITS_SHOW_HOME_PAGE_ELEMENTS == true
) )
} }
} }
@@ -1,6 +1,7 @@
window.app.component('lnbits-label-selector', { window.app.component('lnbits-label-selector', {
template: '#lnbits-label-selector', template: '#lnbits-label-selector',
props: ['labels'], props: ['labels'],
mixins: [window.windowMixin],
data() { data() {
return { return {
labelFilter: '', labelFilter: '',
@@ -1,16 +1,6 @@
window.app.component('lnbits-language-dropdown', { window.app.component('lnbits-language-dropdown', {
template: '#lnbits-language-dropdown', template: '#lnbits-language-dropdown',
computed: { mixins: [window.windowMixin],
currentLanguage() {
return (
this.langs.find(lang => lang.value === window.i18n.global.locale) || {
value: 'en',
label: 'English',
display: '🇬🇧 EN'
}
)
}
},
methods: { methods: {
activeLanguage(lang) { activeLanguage(lang) {
return window.i18n.global.locale === lang return window.i18n.global.locale === lang
@@ -19,7 +9,6 @@ window.app.component('lnbits-language-dropdown', {
this.g.locale = newValue this.g.locale = newValue
window.i18n.global.locale = newValue window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue) this.$q.localStorage.set('lnbits.lang', newValue)
this.$emit('language-changed', newValue)
} }
}, },
data() { data() {
@@ -1,4 +1,5 @@
window.app.component('lnbits-manage-extension-list', { window.app.component('lnbits-manage-extension-list', {
mixins: [window.windowMixin],
template: '#lnbits-manage-extension-list', template: '#lnbits-manage-extension-list',
data() { data() {
return { return {
@@ -8,18 +9,27 @@ window.app.component('lnbits-manage-extension-list', {
} }
}, },
watch: { watch: {
'g.user.extensions'() { 'g.user.extensions': {
this.loadExtensions() async handler() {
await this.loadExtensions()
}
}, },
searchTerm() { searchTerm() {
this.filterUserExtensionsByTerm() this.filterUserExtensionsByTerm()
} }
}, },
methods: { methods: {
map(data) {
const obj = {...data}
obj.url = ['/', obj.code, '/'].join('')
return obj
},
async loadExtensions() { async loadExtensions() {
try { try {
res = await LNbits.api.request('GET', '/api/v1/extension') const {data} = await LNbits.api.request('GET', '/api/v1/extension')
this.extensions = res.data.sort((a, b) => a.name.localeCompare(b.name)) this.extensions = data
.map(extension => this.map(extension))
.sort((a, b) => a.name.localeCompare(b.name))
this.filterUserExtensionsByTerm() this.filterUserExtensionsByTerm()
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
@@ -35,6 +45,10 @@ window.app.component('lnbits-manage-extension-list', {
.toLocaleLowerCase() .toLocaleLowerCase()
.includes(this.searchTerm.toLocaleLowerCase()) .includes(this.searchTerm.toLocaleLowerCase())
}) })
.map(obj => {
obj.isActive = window.location.pathname.startsWith(obj.url)
return obj
})
} }
}, },
async created() { async created() {
@@ -1,91 +1,9 @@
window.app.component('lnbits-manage-wallet-list', { window.app.component('lnbits-manage-wallet-list', {
template: '#lnbits-manage-wallet-list', template: '#lnbits-manage-wallet-list',
mixins: [window.windowMixin],
data() { data() {
return { return {
activeWalletId: null walletName: ''
}
},
computed: {
maxWallets() {
return this.g.user?.extra?.visible_wallet_count || 10
}
},
watch: {
$route(to) {
if (to.path.startsWith('/wallet/')) {
this.activeWalletId = to.params.id
} else {
this.activeWalletId = null
}
}
},
created() {
if (this.g.user && this.g.walletEventListeners.length === 0) {
this.paymentEvents()
}
},
methods: {
openNewWalletDialog() {
if (this.g.user.walletInvitesCount) {
this.g.newWalletType = 'lightning-shared'
} else {
this.g.newWalletType = 'lightning'
}
},
onWebsocketMessage(ev) {
const data = JSON.parse(ev.data)
if (!data.payment) {
console.error('ws message no payment', data)
return
}
// update sidebar wallet balances
this.g.user.wallets.forEach(w => {
if (w.id === data.payment.wallet_id) {
w.sat = data.wallet_balance
}
})
// if current wallet, update balance and payments
if (this.g.wallet.id === data.payment.wallet_id) {
this.g.wallet.sat = data.wallet_balance
// lnbits-payment-list is watching
this.g.updatePayments = !this.g.updatePayments
this.g.updatePaymentsHash = !this.g.updatePaymentsHash
}
// NOTE: react only on incoming payments for now
if (data.payment.amount > 0) {
eventReaction(data.wallet_balance * 1000)
}
},
paymentEvents() {
if (!this.g.user) return
let timeout
const wallets = this.g.user.wallets.slice(0, this.maxWallets)
wallets.forEach(wallet => {
if (!this.g.walletEventListeners.includes(wallet.id)) {
this.g.walletEventListeners.push(wallet.id)
const ws = new WebSocket(`${websocketUrl}/${wallet.inkey}`)
ws.onmessage = this.onWebsocketMessage
ws.onopen = () => console.log('ws connected for wallet', wallet.id)
// onclose and onerror can both happen on their own or together,
// so we add a clearTimeout to avoid multiple reconnections
ws.onclose = () => {
console.log('ws closed, reconnecting...', wallet.id)
this.g.walletEventListeners = this.g.walletEventListeners.filter(
id => id !== wallet.id
)
clearTimeout(timeout)
timeout = setTimeout(this.paymentEvents, 5000)
}
ws.onerror = () => {
console.warn('ws error, reconnecting...', wallet.id)
this.g.walletEventListeners = this.g.walletEventListeners.filter(
id => id !== wallet.id
)
clearTimeout(timeout)
timeout = setTimeout(this.paymentEvents, 5000)
}
}
})
} }
} }
}) })
@@ -1,8 +1,10 @@
window.app.component('lnbits-payment-list', { window.app.component('lnbits-payment-list', {
template: '#lnbits-payment-list', template: '#lnbits-payment-list',
props: ['wallet', 'paymentFilter'], props: ['wallet', 'paymentFilter'],
mixins: [window.windowMixin],
data() { data() {
return { return {
denomination: LNBITS_DENOMINATION,
payments: [], payments: [],
paymentsTable: { paymentsTable: {
columns: [ columns: [
@@ -16,7 +18,7 @@ window.app.component('lnbits-payment-list', {
{ {
name: 'amount', name: 'amount',
align: 'right', align: 'right',
label: this.$t('amount'), label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
field: 'sat', field: 'sat',
sortable: true sortable: true
} }
@@ -28,13 +30,6 @@ window.app.component('lnbits-payment-list', {
descending: true, descending: true,
rowsNumber: 10 rowsNumber: 10
}, },
sortFields: [
{name: 'amount', label: 'Amount'},
{name: 'fee', label: 'Fee'},
{name: 'memo', label: 'Memo'},
{name: 'time', label: 'Creation Date'},
{name: 'updated_at', label: 'Last Updated'}
],
search: '', search: '',
loading: false loading: false
}, },
@@ -51,10 +46,10 @@ window.app.component('lnbits-payment-list', {
paymentsCSV: { paymentsCSV: {
columns: [ columns: [
{ {
name: 'status', name: 'pending',
align: 'right', align: 'left',
label: this.$t('status'), label: 'Pending',
field: 'status' field: 'pending'
}, },
{ {
name: 'memo', name: 'memo',
@@ -72,14 +67,14 @@ window.app.component('lnbits-payment-list', {
{ {
name: 'amount', name: 'amount',
align: 'right', align: 'right',
label: this.$t('amount'), label: this.$t('amount') + ' (' + LNBITS_DENOMINATION + ')',
field: 'sat', field: 'sat',
sortable: true sortable: true
}, },
{ {
name: 'fee', name: 'fee',
align: 'right', align: 'right',
label: this.$t('fee'), label: this.$t('fee') + ' (m' + LNBITS_DENOMINATION + ')',
field: 'fee' field: 'fee'
}, },
{ {
@@ -132,6 +127,9 @@ window.app.component('lnbits-payment-list', {
} }
}, },
computed: { computed: {
currentWallet() {
return this.wallet || this.g.wallet
},
filteredPayments() { filteredPayments() {
const q = this.paymentsTable.search const q = this.paymentsTable.search
if (!q || q === '') return this.payments if (!q || q === '') return this.payments
@@ -169,14 +167,16 @@ window.app.component('lnbits-payment-list', {
fiat_currency: data.fiat_currency, fiat_currency: data.fiat_currency,
labels: data.labels labels: data.labels
} }
obj.date = this.utils.formatDate(data.created_at) obj.date = moment.utc(data.created_at).local().format(window.dateFormat)
obj.dateFrom = this.utils.formatDateFrom(data.created_at) obj.dateFrom = moment.utc(data.created_at).local().fromNow()
obj.expirydate = this.utils.formatDate(data.expiry) obj.expirydate = moment.utc(obj.expiry).local().format(window.dateFormat)
obj.expirydateFrom = this.utils.formatDateFrom(data.expiry) obj.expirydateFrom = moment.utc(obj.expiry).local().fromNow()
obj.msat = obj.amount obj.msat = obj.amount
obj.sat = obj.msat / 1000 obj.sat = obj.msat / 1000
obj.tag = obj.extra?.tag obj.tag = obj.extra?.tag
obj.fsat = this.utils.formatSat(obj.sat) obj.fsat = new Intl.NumberFormat(window.i18n.global.locale).format(
obj.sat
)
obj.isIn = obj.amount > 0 obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0 obj.isOut = obj.amount < 0
obj.isPending = obj.status === 'pending' obj.isPending = obj.status === 'pending'
@@ -203,6 +203,7 @@ window.app.component('lnbits-payment-list', {
if (this.searchDate.to) { if (this.searchDate.to) {
this.paymentFilter['time[le]'] = this.searchDate.to + 'T23:59:59' this.paymentFilter['time[le]'] = this.searchDate.to + 'T23:59:59'
} }
this.fetchPayments() this.fetchPayments()
}, },
searchByLabels(labels) { searchByLabels(labels) {
@@ -226,39 +227,29 @@ window.app.component('lnbits-payment-list', {
this.fetchPayments() this.fetchPayments()
}, },
fetchPayments(props) { fetchPayments(props) {
this.paymentsTable.loading = true
const params = LNbits.utils.prepareFilterQuery( const params = LNbits.utils.prepareFilterQuery(
this.paymentsTable, this.paymentsTable,
props, props,
this.paymentFilter this.paymentFilter
) )
this.paymentsTable.loading = true
return LNbits.api return LNbits.api
.getPayments(this.wallet, params) .getPayments(this.currentWallet, params)
.then(response => { .then(response => {
this.paymentsTable.loading = false
this.paymentsTable.pagination.rowsNumber = response.data.total this.paymentsTable.pagination.rowsNumber = response.data.total
this.payments = response.data.data.map(this.mapPayment) this.payments = response.data.data.map(this.mapPayment)
this.paymentsTable.loading = false
this.recheckPendingPayments() this.recheckPendingPayments()
}) })
.catch(err => { .catch(err => {
this.paymentsTable.loading = false this.paymentsTable.loading = false
if (g.user.admin) { if (g.user.admin) {
this.fetchPaymentsAsAdmin(this.wallet.id, params) this.fetchPaymentsAsAdmin(this.currentWallet.id, params)
} else { } else {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
} }
}) })
}, },
sortByColumn(columnName) {
if (this.paymentsTable.pagination.sortBy === columnName) {
this.paymentsTable.pagination.descending =
!this.paymentsTable.pagination.descending
} else {
this.paymentsTable.pagination.sortBy = columnName
this.paymentsTable.pagination.descending = false
}
this.fetchPayments()
},
fetchPaymentsAsAdmin(walletId, params) { fetchPaymentsAsAdmin(walletId, params) {
params = (params || '') + '&wallet_id=' + walletId params = (params || '') + '&wallet_id=' + walletId
return LNbits.api return LNbits.api
@@ -275,7 +266,7 @@ window.app.component('lnbits-payment-list', {
}, },
checkPayment(payment_hash) { checkPayment(payment_hash) {
LNbits.api LNbits.api
.getPayment(this.wallet, payment_hash) .getPayment(this.g.wallet, payment_hash)
.then(res => { .then(res => {
this.update = !this.update this.update = !this.update
if (res.data.status == 'success') { if (res.data.status == 'success') {
@@ -303,7 +294,7 @@ window.app.component('lnbits-payment-list', {
].join('&') ].join('&')
LNbits.api LNbits.api
.getPayments(this.wallet, params) .getPayments(this.currentWallet, params)
.then(response => { .then(response => {
let updatedPayments = 0 let updatedPayments = 0
response.data.data.forEach(updatedPayment => { response.data.data.forEach(updatedPayment => {
@@ -335,7 +326,7 @@ window.app.component('lnbits-payment-list', {
}, },
cancelHoldInvoice(payment_hash) { cancelHoldInvoice(payment_hash) {
LNbits.api LNbits.api
.cancelInvoice(this.wallet, payment_hash) .cancelInvoice(this.g.wallet, payment_hash)
.then(() => { .then(() => {
this.update = !this.update this.update = !this.update
Quasar.Notify.create({ Quasar.Notify.create({
@@ -347,7 +338,7 @@ window.app.component('lnbits-payment-list', {
}, },
settleHoldInvoice(preimage) { settleHoldInvoice(preimage) {
LNbits.api LNbits.api
.settleInvoice(this.wallet, preimage) .settleInvoice(this.g.wallet, preimage)
.then(() => { .then(() => {
this.update = !this.update this.update = !this.update
Quasar.Notify.create({ Quasar.Notify.create({
@@ -370,7 +361,7 @@ window.app.component('lnbits-payment-list', {
direction: pagination.descending ? 'desc' : 'asc' direction: pagination.descending ? 'desc' : 'asc'
} }
const params = new URLSearchParams(query) const params = new URLSearchParams(query)
LNbits.api.getPayments(this.wallet, params).then(response => { LNbits.api.getPayments(this.g.wallet, params).then(response => {
let payments = response.data.data.map(this.mapPayment) let payments = response.data.data.map(this.mapPayment)
let columns = this.paymentsCSV.columns let columns = this.paymentsCSV.columns
@@ -398,7 +389,7 @@ window.app.component('lnbits-payment-list', {
LNbits.utils.exportCSV( LNbits.utils.exportCSV(
columns, columns,
payments, payments,
this.wallet.name + '-payments' this.g.wallet.name + '-payments'
) )
}) })
}, },
@@ -467,7 +458,7 @@ window.app.component('lnbits-payment-list', {
await LNbits.api.request( await LNbits.api.request(
'PUT', 'PUT',
`/api/v1/payments/${this.selectedPayment.payment_hash}/labels`, `/api/v1/payments/${this.selectedPayment.payment_hash}/labels`,
this.wallet.adminkey, this.g.wallet.adminkey,
{ {
labels: labels labels: labels
} }
@@ -1,5 +1,6 @@
window.app.component('lnbits-qrcode-lnurl', { window.app.component('lnbits-qrcode-lnurl', {
template: '#lnbits-qrcode-lnurl', template: '#lnbits-qrcode-lnurl',
mixins: [window.windowMixin],
props: { props: {
url: { url: {
required: true, required: true,
@@ -1,74 +0,0 @@
window.app.component('lnbits-qrcode-scanner', {
template: '#lnbits-qrcode-scanner',
props: ['callback'],
data() {
return {
showScanner: false
}
},
watch: {
callback(newVal) {
if (newVal === null) {
return this.reset()
}
if (typeof newVal !== 'function') {
Quasar.Notify.create({
message: 'QR code scanner callback is not a function.',
type: 'negative'
})
return this.reset()
}
if (this.g.hasCamera === false) {
Quasar.Notify.create({
message: 'No camera found on this device.',
type: 'negative'
})
return this.reset()
}
this.showScanner = true
}
},
methods: {
reset() {
this.showScanner = false
this.g.scanner = null
},
detect(val) {
const rawValue = val[0].rawValue
console.log('Detected QR code value:', rawValue)
this.callback(rawValue)
this.$emit('detect', rawValue)
this.reset()
},
async onInitQR(promise) {
try {
await promise
} catch (error) {
const mapping = {
NotAllowedError: 'ERROR: you need to grant camera access permission',
NotFoundError: 'ERROR: no camera on this device',
NotSupportedError:
'ERROR: secure context required (HTTPS, localhost)',
NotReadableError: 'ERROR: is the camera already in use?',
OverconstrainedError: 'ERROR: installed cameras are not suitable',
StreamApiNotSupportedError:
'ERROR: Stream API is not supported in this browser',
InsecureContextError:
'ERROR: Camera access is only permitted in secure context. Use HTTPS or localhost rather than HTTP.'
}
const valid_error = Object.keys(mapping).filter(key => {
return error.name === key
})
const camera_error = valid_error
? mapping[valid_error]
: `ERROR: Camera error (${error.name})`
Quasar.Notify.create({
message: camera_error,
type: 'negative'
})
this.g.hasCamera = false
this.reset()
}
}
}
})
+4 -42
View File
@@ -1,7 +1,8 @@
window.app.component('lnbits-qrcode', { window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
template: '#lnbits-qrcode', template: '#lnbits-qrcode',
components: { components: {
QrcodeVue: QrcodeVue.default QrcodeVue
}, },
props: { props: {
value: { value: {
@@ -12,10 +13,6 @@ window.app.component('lnbits-qrcode', {
type: Boolean, type: Boolean,
default: false default: false
}, },
print: {
type: Boolean,
default: false
},
showButtons: { showButtons: {
type: Boolean, type: Boolean,
default: true default: true
@@ -34,7 +31,7 @@ window.app.component('lnbits-qrcode', {
}, },
logo: { logo: {
type: String, type: String,
default: window.g.settings.qrLogo || null default: LNBITS_QR_LOGO
} }
}, },
data() { data() {
@@ -44,44 +41,9 @@ window.app.component('lnbits-qrcode', {
} }
}, },
methods: { methods: {
printQrCode() {
const svg = this.$refs.qrCode.$el.outerHTML
const printWindow = window.open('', '_blank')
printWindow.document.write(`
<html>
<head>
<title>Print QR Code</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
img {
position: absolute;
max-width: 51px;
width: 14%;
overflow: hidden;
background: #fff;
overflow: hidden;
padding: -1.2rem;
border-radius: -1.2rem;
}
</style>
</head>
<body>${svg}<img src="${this.logo}"></body>
</html>
`)
printWindow.document.close()
printWindow.focus()
printWindow.print()
printWindow.close()
},
clickQrCode(event) { clickQrCode(event) {
if (this.href === '') { if (this.href === '') {
this.utils.copyText(this.value) this.copyText(this.value)
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return false return false
+1 -11
View File
@@ -1,4 +1,5 @@
window.app.component('lnbits-theme', { window.app.component('lnbits-theme', {
mixins: [window.windowMixin],
watch: { watch: {
'g.walletFlip'(val) { 'g.walletFlip'(val) {
this.$q.localStorage.setItem('lnbits.walletFlip', val) this.$q.localStorage.setItem('lnbits.walletFlip', val)
@@ -9,10 +10,6 @@ window.app.component('lnbits-theme', {
'g.disclaimerShown'(val) { 'g.disclaimerShown'(val) {
this.$q.localStorage.setItem('lnbits.disclaimerShown', val) this.$q.localStorage.setItem('lnbits.disclaimerShown', val)
}, },
'g.locale'(val) {
this.$q.localStorage.setItem('lnbits.lang', val)
window.i18n.global.locale = val
},
'g.isFiatPriority'(val) { 'g.isFiatPriority'(val) {
this.$q.localStorage.setItem('lnbits.isFiatPriority', val) this.$q.localStorage.setItem('lnbits.isFiatPriority', val)
}, },
@@ -127,13 +124,6 @@ window.app.component('lnbits-theme', {
if (this.g.mobileSimple === true) { if (this.g.mobileSimple === true) {
document.body.classList.add('mobile-simple') document.body.classList.add('mobile-simple')
} }
Object.entries(this.g.user?.uiCustomization || {}).forEach(
([key, value]) => {
if (key in this.g) {
this.g[key] = value
}
}
)
this.checkUrlParams() this.checkUrlParams()
} }
}) })
@@ -1,5 +1,6 @@
window.app.component('lnbits-wallet-api-docs', { window.app.component('lnbits-wallet-api-docs', {
template: '#lnbits-wallet-api-docs', template: '#lnbits-wallet-api-docs',
mixins: [window.windowMixin],
methods: { methods: {
resetKeys() { resetKeys() {
LNbits.utils LNbits.utils
@@ -38,7 +39,6 @@ window.app.component('lnbits-wallet-api-docs', {
}, },
data() { data() {
return { return {
origin: window.location.origin,
inkeyHidden: true, inkeyHidden: true,
adminkeyHidden: true, adminkeyHidden: true,
walletIdHidden: true walletIdHidden: true
@@ -1,5 +1,6 @@
window.app.component('lnbits-wallet-charts', { window.app.component('lnbits-wallet-charts', {
template: '#lnbits-wallet-charts', template: '#lnbits-wallet-charts',
mixins: [window.windowMixin],
props: ['paymentFilter', 'chartConfig'], props: ['paymentFilter', 'chartConfig'],
data() { data() {
return { return {
@@ -101,62 +102,63 @@ window.app.component('lnbits-wallet-charts', {
if (this.walletBalanceInOut) { if (this.walletBalanceInOut) {
this.walletBalanceInOut.destroy() this.walletBalanceInOut.destroy()
} }
const ref = this.$refs.walletBalanceInOut this.walletBalanceInOut = new Chart(
if (!ref) return this.$refs.walletBalanceInOut.getContext('2d'),
this.walletBalanceInOut = new Chart(ref.getContext('2d'), { {
type: 'bar', type: 'bar',
options: this.barOptions, options: this.barOptions,
data: { data: {
labels, labels,
datasets: [ datasets: [
{ {
label: 'Balance In', label: 'Balance In',
borderRadius: 5, borderRadius: 5,
data: data.map(s => s.balance_in), data: data.map(s => s.balance_in),
backgroundColor: this.colorPrimary backgroundColor: this.colorPrimary
}, },
{ {
label: 'Balance Out', label: 'Balance Out',
borderRadius: 5, borderRadius: 5,
data: data.map(s => s.balance_out), data: data.map(s => s.balance_out),
backgroundColor: this.colorSecondary backgroundColor: this.colorSecondary
} }
] ]
}
} }
}) )
}, },
drawPaymentInOut(data, labels) { drawPaymentInOut(data, labels) {
if (this.walletPaymentInOut) { if (this.walletPaymentInOut) {
this.walletPaymentInOut.destroy() this.walletPaymentInOut.destroy()
} }
const ref = this.$refs.walletPaymentInOut this.walletPaymentInOut = new Chart(
if (!ref) return this.$refs.walletPaymentInOut.getContext('2d'),
this.walletPaymentInOut = new Chart(ref.getContext('2d'), { {
type: 'bar', type: 'bar',
options: this.barOptions, options: this.barOptions,
data: { data: {
labels, labels,
datasets: [ datasets: [
{ {
label: 'Payments In', label: 'Payments In',
data: data.map(s => s.count_in), data: data.map(s => s.count_in),
backgroundColor: this.colorPrimary backgroundColor: this.colorPrimary
}, },
{ {
label: 'Payments Out', label: 'Payments Out',
data: data.map(s => -s.count_out), data: data.map(s => -s.count_out),
backgroundColor: this.colorSecondary backgroundColor: this.colorSecondary
} }
] ]
}
} }
}) )
}, },
drawBalanceChart(data, labels) { drawBalanceChart(data, labels) {
const ref = this.$refs.walletBalanceChart
if (this.walletBalanceChart) { if (this.walletBalanceChart) {
this.walletBalanceChart.destroy() this.walletBalanceChart.destroy()
} }
const ref = this.$refs.walletBalanceChart
if (!ref) return
this.walletBalanceChart = new Chart(ref.getContext('2d'), { this.walletBalanceChart = new Chart(ref.getContext('2d'), {
type: 'line', type: 'line',
options: { options: {
@@ -1,10 +1,9 @@
window.app.component('lnbits-wallet-extra', { window.app.component('lnbits-wallet-extra', {
template: '#lnbits-wallet-extra', template: '#lnbits-wallet-extra',
mixins: [window.windowMixin],
props: ['chartConfig'], props: ['chartConfig'],
computed: { data() {
exportUrl() { return {}
return `${window.location.origin}/wallet?usr=${this.g.user.id}&wal=${this.g.wallet.id}`
}
}, },
methods: { methods: {
handleSendLnurl(lnurl) { handleSendLnurl(lnurl) {
@@ -30,12 +29,7 @@ window.app.component('lnbits-wallet-extra', {
.onOk(() => { .onOk(() => {
LNbits.api LNbits.api
.deleteWallet(this.g.wallet) .deleteWallet(this.g.wallet)
.then(() => { .then(_ => {
this.g.user.wallets = this.g.user.wallets.filter(
w => w.id !== this.g.wallet.id
)
this.g.lastActiveWallet = this.g.user.wallets[0].id
this.$router.push(`/wallet/${this.g.lastActiveWallet}`)
Quasar.Notify.create({ Quasar.Notify.create({
timeout: 3000, timeout: 3000,
message: `Wallet deleted!`, message: `Wallet deleted!`,
@@ -71,10 +65,6 @@ window.app.component('lnbits-wallet-extra', {
'lnbits.exchangeRate.' + this.g.wallet.currency, 'lnbits.exchangeRate.' + this.g.wallet.currency,
this.g.exchangeRate this.g.exchangeRate
) )
if (this.g.exchangeRate <= 0) {
this.g.fiatTracking = false
this.g.isFiatPriority = false
}
}) })
.catch(e => console.error(e)) .catch(e => console.error(e))
} }
@@ -1,5 +1,6 @@
window.app.component('lnbits-wallet-icon', { window.app.component('lnbits-wallet-icon', {
template: '#lnbits-wallet-icon', template: '#lnbits-wallet-icon',
mixins: [window.windowMixin],
data() { data() {
return { return {
icon: { icon: {
@@ -1,29 +1,13 @@
window.app.component('lnbits-wallet-new', { window.app.component('lnbits-wallet-new', {
template: '#lnbits-wallet-new', template: '#lnbits-wallet-new',
mixins: [window.windowMixin],
data() { data() {
return { return {
walletTypes: [{label: 'Lightning Wallet', value: 'lightning'}], walletTypes: [{label: 'Lightning Wallet', value: 'lightning'}],
wallet: {name: '', sharedWalletId: ''}, newWallet: {name: '', sharedWalletId: ''}
showNewWalletDialog: false
}
},
watch: {
'g.newWalletType'(val) {
if (val === null) return
this.showNewWalletDialog = true
},
showNewWalletDialog(val) {
if (val === true) return
this.reset()
} }
}, },
computed: { computed: {
isLightning() {
return this.g.newWalletType === 'lightning'
},
isLightningShared() {
return this.g.newWalletType === 'lightning-shared'
},
inviteWalletOptions() { inviteWalletOptions() {
return (this.g.user?.extra?.wallet_invite_requests || []).map(i => ({ return (this.g.user?.extra?.wallet_invite_requests || []).map(i => ({
label: `${i.to_wallet_name} (from ${i.from_user_name})`, label: `${i.to_wallet_name} (from ${i.from_user_name})`,
@@ -32,16 +16,11 @@ window.app.component('lnbits-wallet-new', {
} }
}, },
methods: { methods: {
reset() {
this.showNewWalletDialog = false
this.g.newWalletType = null
this.wallet = {name: '', sharedWalletId: ''}
},
async submitRejectWalletInvitation() { async submitRejectWalletInvitation() {
try { try {
const inviteRequests = this.g.user.extra.wallet_invite_requests || [] const inviteRequests = this.g.user.extra.wallet_invite_requests || []
const invite = inviteRequests.find( const invite = inviteRequests.find(
invite => invite.to_wallet_id === this.wallet.sharedWalletId invite => invite.to_wallet_id === this.newWallet.sharedWalletId
) )
if (!invite) { if (!invite) {
Quasar.Notify.create({ Quasar.Notify.create({
@@ -67,8 +46,8 @@ window.app.component('lnbits-wallet-new', {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
} }
}, },
submitAddWallet() { async submitAddWallet() {
const data = this.wallet const data = this.newWallet
if (this.g.newWalletType === 'lightning' && !data.name) { if (this.g.newWalletType === 'lightning' && !data.name) {
this.$q.notify({ this.$q.notify({
message: 'Please enter a name for the wallet', message: 'Please enter a name for the wallet',
@@ -76,6 +55,7 @@ window.app.component('lnbits-wallet-new', {
}) })
return return
} }
if (this.g.newWalletType === 'lightning-shared' && !data.sharedWalletId) { if (this.g.newWalletType === 'lightning-shared' && !data.sharedWalletId) {
this.$q.notify({ this.$q.notify({
message: 'Missing a shared wallet ID', message: 'Missing a shared wallet ID',
@@ -83,21 +63,19 @@ window.app.component('lnbits-wallet-new', {
}) })
return return
} }
LNbits.api try {
.createWallet(data.name, this.g.newWalletType, { await LNbits.api.createWallet(
shared_wallet_id: data.sharedWalletId this.g.user.wallets[0],
}) data.name,
.then(res => { this.g.newWalletType,
this.$q.notify({ {
message: 'Wallet created successfully', shared_wallet_id: data.sharedWalletId
color: 'positive' }
}) )
this.reset() } catch (e) {
this.g.user.wallets.push(LNbits.map.wallet(res.data)) console.warn(e)
this.g.lastWalletId = res.data.id LNbits.utils.notifyApiError(e)
this.$router.push(`/wallet/${res.data.id}`) }
})
.catch(LNbits.utils.notifyApiError)
} }
}, },
created() { created() {
@@ -1,5 +1,6 @@
window.app.component('lnbits-wallet-paylinks', { window.app.component('lnbits-wallet-paylinks', {
template: '#lnbits-wallet-paylinks', template: '#lnbits-wallet-paylinks',
mixins: [window.windowMixin],
data() { data() {
return { return {
storedPaylinks: [] storedPaylinks: []
@@ -14,6 +15,10 @@ window.app.component('lnbits-wallet-paylinks', {
this.storedPaylinks = this.g.wallet.storedPaylinks this.storedPaylinks = this.g.wallet.storedPaylinks
}, },
methods: { methods: {
dateFromNow(unix) {
const date = new Date(unix * 1000)
return moment.utc(date).local().fromNow()
},
updatePaylinks() { updatePaylinks() {
LNbits.api LNbits.api
.request( .request(
@@ -1,5 +1,6 @@
window.app.component('lnbits-wallet-share', { window.app.component('lnbits-wallet-share', {
template: '#lnbits-wallet-share', template: '#lnbits-wallet-share',
mixins: [window.windowMixin],
computed: { computed: {
walletApprovedShares() { walletApprovedShares() {
return this.g.wallet.extra.shared_with.filter( return this.g.wallet.extra.shared_with.filter(
@@ -25,7 +26,7 @@ window.app.component('lnbits-wallet-share', {
{label: 'Send', value: 'send-payments'} {label: 'Send', value: 'send-payments'}
], ],
walletShareInvite: { walletShareInvite: {
username: '', unsername: '',
permissions: [] permissions: []
} }
} }
+1
View File
@@ -473,6 +473,7 @@ function confettiStars() {
F = t.width * I.x, F = t.width * I.x,
N = t.height * I.y; N = t.height * I.y;
E--; E--;
) )
S.push( S.push(
((o = { ((o = {
+36 -43
View File
@@ -9,22 +9,22 @@ const localStore = (key, defaultValue) => {
} }
window.g = Vue.reactive({ window.g = Vue.reactive({
// vars from server isUserAuthorized: !!Quasar.Cookies.get('is_lnbits_user_authorized'),
settings: SETTINGS, offline: !navigator.onLine,
currencies: CURRENCIES, visibleDrawer: false,
extensions: SETTINGS.extensions, extensions: [],
allowedCurrencies: SETTINGS.allowedCurrencies, user: null,
denomination: SETTINGS.denomination, wallet: {},
isSatsDenomination: SETTINGS.denomination == 'sats', fiatBalance: 0,
// local storage vars exchangeRate: 0,
themeChoice: localStore('lnbits.theme', SETTINGS.defaultTheme), fiatTracking: false,
borderChoice: localStore('lnbits.border', SETTINGS.defaultBorder), payments: [],
gradientChoice: localStore('lnbits.gradientBg', SETTINGS.defaultGradient), walletEventListeners: [],
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction), showNewWalletDialog: false,
bgimageChoice: localStore( newWalletType: 'lightning',
'lnbits.backgroundImage', updatePayments: false,
SETTINGS.defaultBgimage || '' currencies: WINDOW_SETTINGS.LNBITS_CURRENCIES ?? [],
), allowedCurrencies: WINDOW_SETTINGS.LNBITS_ALLOWED_CURRENCIES ?? [],
locale: localStore('lnbits.lang', navigator.languages[1] ?? 'en'), locale: localStore('lnbits.lang', navigator.languages[1] ?? 'en'),
disclaimerShown: localStore('lnbits.disclaimerShown', false), disclaimerShown: localStore('lnbits.disclaimerShown', false),
isFiatPriority: localStore('lnbits.isFiatPriority', false), isFiatPriority: localStore('lnbits.isFiatPriority', false),
@@ -32,27 +32,26 @@ window.g = Vue.reactive({
walletFlip: localStore('lnbits.walletFlip', false), walletFlip: localStore('lnbits.walletFlip', false),
lastActiveWallet: localStore('lnbits.lastActiveWallet', null), lastActiveWallet: localStore('lnbits.lastActiveWallet', null),
darkChoice: localStore('lnbits.darkMode', true), darkChoice: localStore('lnbits.darkMode', true),
// cookie vars themeChoice: localStore('lnbits.theme', WINDOW_SETTINGS.LNBITS_DEFAULT_THEME),
isUserAuthorized: !!Quasar.Cookies.get('is_lnbits_user_authorized'), borderChoice: localStore(
isUserImpersonated: !!Quasar.Cookies.get('is_lnbits_user_impersonated'), 'lnbits.border',
// frontend vars WINDOW_SETTINGS.LNBITS_DEFAULT_BORDER || 'hard-border'
errorCode: null, ),
errorMessage: null, gradientChoice: localStore(
user: null, 'lnbits.gradientBg',
wallet: null, WINDOW_SETTINGS.LNBITS_DEFAULT_GRADIENT || false
isPublicPage: true, ),
offline: !navigator.onLine, reactionChoice: localStore(
hasCamera: false, 'lnbits.reactions',
visibleDrawer: false, WINDOW_SETTINGS.LNBITS_DEFAULT_REACTION || 'confettiBothSides'
fiatBalance: 0, ),
exchangeRate: 0, bgimageChoice: localStore(
fiatTracking: false, 'lnbits.backgroundImage',
payments: [], WINDOW_SETTINGS.LNBITS_DEFAULT_BGIMAGE || ''
walletEventListeners: [], ),
updatePayments: false, // used for updating the lnbits-payment-list ads: WINDOW_SETTINGS.AD_SPACE.split(',').map(ad => ad.split(';')),
updatePaymentsHash: false, // used for closing the receive dialog denomination: WINDOW_SETTINGS.LNBITS_DENOMINATION,
scanner: null, isSatsDenomination: WINDOW_SETTINGS.LNBITS_DENOMINATION == 'sats'
newWalletType: null
}) })
window.dateFormat = 'YYYY-MM-DD HH:mm' window.dateFormat = 'YYYY-MM-DD HH:mm'
@@ -78,9 +77,3 @@ if (navigator.serviceWorker != null) {
console.log('Registered events at scope: ', registration.scope) console.log('Registered events at scope: ', registration.scope)
}) })
} }
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
navigator.mediaDevices.enumerateDevices().then(devices => {
window.g.hasCamera = devices.some(device => device.kind === 'videoinput')
})
}
+9 -74
View File
@@ -2,51 +2,10 @@ const quasarConfig = {
config: { config: {
loading: { loading: {
spinner: Quasar.QSpinnerBars spinner: Quasar.QSpinnerBars
},
table: {
rowsPerPageOptions: [5, 10, 20, 50, 100, 200, 500, 0]
} }
} }
} }
const DynamicComponent = {
async created() {
const name = this.$route.path.split('/')[1]
const path = `/${name}/`
const routesPath = `/${name}/static/routes.json`
if (this.$router.getRoutes().some(r => r.path === path)) return
if (this.$route.fullPath.startsWith('/extensions/builder/preview')) return
fetch(routesPath)
.then(async res => {
if (!res.ok) throw new Error('No dynamic routes found')
const routes = await res.json()
routes.forEach(r => {
console.log('Adding dynamic route:', r.path)
window.router.addRoute({
path: r.path,
name: r.name,
component: async () => {
await LNbits.utils.loadTemplate(r.template)
await LNbits.utils.loadScript(r.component)
return window[r.name]
}
})
window.router.push(this.$route.fullPath)
})
})
.catch(() => {
let route = RENDERED_ROUTE
// append trailing slash only on the root path `/path` -> `/path/`
if (route.split('/').length === 2) route += '/'
if (route !== this.$route.path) {
console.log('Redirecting to non-vue route:', this.$route.fullPath)
window.location = this.$route.fullPath
return
}
})
}
}
const routes = [ const routes = [
{ {
path: '/node', path: '/node',
@@ -70,14 +29,6 @@ const routes = [
}, },
{ {
path: '/wallet', path: '/wallet',
redirect: to => {
const walletId =
window.g?.lastActiveWallet || window.g?.user?.wallets[0].id
return `/wallet/${to.query.wal || walletId || 'default'}`
}
},
{
path: '/wallet/:id',
name: 'Wallet', name: 'Wallet',
component: PageWallet component: PageWallet
}, },
@@ -106,11 +57,6 @@ const routes = [
name: 'ExtensionsBuilder', name: 'ExtensionsBuilder',
component: PageExtensionBuilder component: PageExtensionBuilder
}, },
{
path: '/extensions/builder/preview',
name: 'ExtensionsBuilderPreview',
component: PageExtensionBuilderPreview
},
{ {
path: '/extensions', path: '/extensions',
name: 'Extensions', name: 'Extensions',
@@ -125,16 +71,6 @@ const routes = [
path: '/', path: '/',
name: 'PageHome', name: 'PageHome',
component: PageHome component: PageHome
},
{
path: '/error',
name: 'PageError',
component: PageError
},
{
path: '/:pathMatch(.*)*',
name: 'DynamicComponent',
component: DynamicComponent
} }
] ]
@@ -143,9 +79,6 @@ window.router = VueRouter.createRouter({
routes routes
}) })
// BACKWARDS compatibility extensions
window.LOCALE = window.g.locale
window.i18n = new VueI18n.createI18n({ window.i18n = new VueI18n.createI18n({
locale: window.g.locale, locale: window.g.locale,
fallbackLocale: 'en', fallbackLocale: 'en',
@@ -155,15 +88,17 @@ window.i18n = new VueI18n.createI18n({
window.app.mixin({ window.app.mixin({
data() { data() {
return { return {
api: window._lnbitsApi, g: window.g,
utils: window._lnbitsUtils, ...WINDOW_SETTINGS
g: window.g
} }
}, },
// backwards compatibility for extensions, should not be used in the future computed: {
methods: { isVueRoute() {
copyText: window._lnbitsUtils.copyText, const currentPath = window.location.pathname
formatBalance: window._lnbitsUtils.formatBalance const matchedRoute = window.router.resolve(currentPath)
const isVueRoute = matchedRoute?.matched?.length > 0
return isVueRoute
}
} }
}) })
+23
View File
@@ -0,0 +1,23 @@
window.decryptLnurlPayAES = (success_action, preimage) => {
let keyb = new Uint8Array(
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
)
return crypto.subtle
.importKey('raw', keyb, {name: 'AES-CBC', length: 256}, false, ['decrypt'])
.then(key => {
let ivb = Uint8Array.from(window.atob(success_action.iv), c =>
c.charCodeAt(0)
)
let ciphertextb = Uint8Array.from(
window.atob(success_action.ciphertext),
c => c.charCodeAt(0)
)
return crypto.subtle.decrypt({name: 'AES-CBC', iv: ivb}, key, ciphertextb)
})
.then(valueb => {
let decoder = new TextDecoder('utf-8')
return decoder.decode(valueb)
})
}

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