Merge branch 'main' of https://github.com/lnbits/lnbits into lnbits-main
This commit is contained in:
commit
d423a0307f
|
@ -10,13 +10,16 @@ DEBUG=false
|
|||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_ADMIN_USERS=""
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok"
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
|
||||
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available
|
||||
LNBITS_ADMIN_UI=false
|
||||
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
|
||||
# Ad space description
|
||||
# LNBITS_AD_SPACE_TITLE="Supported by"
|
||||
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
|
||||
# LNBITS_AD_SPACE=""
|
||||
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
|
||||
|
||||
# Hides wallet api, extensions can choose to honor
|
||||
LNBITS_HIDE_API=false
|
||||
|
@ -105,6 +108,6 @@ LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
|||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
# Cashu Mint
|
||||
# Use a long-enough random (!) private key.
|
||||
# Use a long-enough random (!) private key.
|
||||
# Once set, you cannot change this key as for now.
|
||||
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
|
||||
|
|
1
.github/workflows/on-tag.yml
vendored
1
.github/workflows/on-tag.yml
vendored
|
@ -7,6 +7,7 @@ on:
|
|||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+-*"
|
||||
|
||||
jobs:
|
||||
|
|
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
|
@ -16,7 +16,7 @@ jobs:
|
|||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
|
@ -43,9 +43,6 @@ jobs:
|
|||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
- name: Install dependencies
|
||||
env:
|
||||
VIRTUAL_ENV: ./venv
|
||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
||||
run: |
|
||||
poetry install
|
||||
- name: Run tests
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.9-slim
|
||||
FROM python:3.10-slim
|
||||
|
||||
RUN apt-get clean
|
||||
RUN apt-get update
|
||||
|
@ -13,7 +13,7 @@ RUN mkdir -p lnbits/data
|
|||
COPY . .
|
||||
|
||||
RUN poetry config virtualenvs.create false
|
||||
RUN poetry install --no-dev --no-root
|
||||
RUN poetry install --only main --no-root
|
||||
RUN poetry run python build.py
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
title: "LNbits docs"
|
||||
|
||||
remote_theme: pmarsceill/just-the-docs
|
||||
logo: "/logos/lnbits-full.png"
|
||||
color_scheme: dark
|
||||
logo: "/logos/lnbits-full--inverse.png"
|
||||
search_enabled: true
|
||||
url: https://legend.lnbits.org
|
||||
aux_links:
|
||||
|
|
|
@ -9,53 +9,10 @@ nav_order: 2
|
|||
Websockets
|
||||
=================
|
||||
|
||||
`websockets` are a great way to add a two way instant data channel between server and client. This example was taken from the `copilot` extension, we create a websocket endpoint which can be restricted by `id`, then can feed it data to broadcast to any client on the socket using the `updater(extension_id, data)` function (`extension` has been used in place of an extension name, wreplace to your own extension):
|
||||
`websockets` are a great way to add a two way instant data channel between server and client.
|
||||
|
||||
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
|
||||
|
||||
```sh
|
||||
from fastapi import Request, WebSocket, WebSocketDisconnect
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket, extension_id: str):
|
||||
await websocket.accept()
|
||||
websocket.id = extension_id
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def send_personal_message(self, message: str, extension_id: str):
|
||||
for connection in self.active_connections:
|
||||
if connection.id == extension_id:
|
||||
await connection.send_text(message)
|
||||
|
||||
async def broadcast(self, message: str):
|
||||
for connection in self.active_connections:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@extension_ext.websocket("/ws/{extension_id}", name="extension.websocket_by_id")
|
||||
async def websocket_endpoint(websocket: WebSocket, extension_id: str):
|
||||
await manager.connect(websocket, extension_id)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
async def updater(extension_id, data):
|
||||
extension = await get_extension(extension_id)
|
||||
if not extension:
|
||||
return
|
||||
await manager.send_personal_message(f"{data}", extension_id)
|
||||
```
|
||||
|
||||
Example vue-js function for listening to the websocket:
|
||||
|
||||
|
@ -67,16 +24,16 @@ initWs: async function () {
|
|||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/extension/ws/' +
|
||||
self.extension.id
|
||||
'/api/v1/ws/' +
|
||||
self.item.id
|
||||
} else {
|
||||
localUrl =
|
||||
'ws://' +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/extension/ws/' +
|
||||
self.extension.id
|
||||
'/api/v1/ws/' +
|
||||
self.item.id
|
||||
}
|
||||
this.ws = new WebSocket(localUrl)
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
|
|
42
docs/guide/admin_ui.md
Normal file
42
docs/guide/admin_ui.md
Normal file
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
layout: default
|
||||
title: Admin UI
|
||||
nav_order: 4
|
||||
---
|
||||
|
||||
|
||||
Admin UI
|
||||
========
|
||||
The LNbits Admin UI lets you change LNbits settings via the LNbits frontend.
|
||||
It is disabled by default and the first time you set the enviroment variable LNBITS_ADMIN_UI=true
|
||||
the settings are initialized and saved to the database and will be used from there as long the UI is enabled.
|
||||
From there on the settings from the database are used.
|
||||
|
||||
|
||||
Super User
|
||||
==========
|
||||
With the Admin UI we introduced the super user, it is created with the initialisation of the Admin UI and will be shown with a success message in the server logs.
|
||||
The super user has access to the server and can change settings that may crash the server and make it unresponsive via the frontend and api, like changing funding sources.
|
||||
|
||||
Also only the super user can brrrr satoshis to different wallets.
|
||||
|
||||
The super user is only stored inside the settings table of the database and after the settings are "reset to defaults" and a restart happened,
|
||||
a new super user is created.
|
||||
|
||||
The super user is never sent over the api and the frontend only receives a bool if you are super user or not.
|
||||
|
||||
We also added a decorator for the API routes to check for super user.
|
||||
|
||||
There is also the possibility of posting the super user via webhook to another service when it is created. you can look it up here https://github.com/lnbits/lnbits/blob/main/lnbits/settings.py `class SaaSSettings`
|
||||
|
||||
|
||||
Admin Users
|
||||
===========
|
||||
enviroment variable: LNBITS_ADMIN_USERS, comma-seperated list of user ids
|
||||
Admin Users can change settings in the admin ui aswell, with the exception of funding source settings, because they require e server restart and could potentially make the server inaccessable. Also they have access to all the extension defined in LNBITS_ADMIN_EXTENSIONS.
|
||||
|
||||
|
||||
Allowed Users
|
||||
=============
|
||||
enviroment variable: LNBITS_ALLOWED_USERS, comma-seperated list of user ids
|
||||
By defining this users, LNbits will no longer be useable by the public, only defined users and admins can then access the LNbits frontend.
|
|
@ -47,6 +47,15 @@ poetry run lnbits
|
|||
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
|
||||
# Note that you have to add the line DEBUG=true in your .env file, too.
|
||||
```
|
||||
#### Updating the server
|
||||
|
||||
```
|
||||
cd lnbits-legend/
|
||||
# Stop LNbits with `ctrl + x`
|
||||
git pull
|
||||
poetry install --only main
|
||||
# Start LNbits with `poetry run lnbits`
|
||||
```
|
||||
|
||||
## Option 2: Nix
|
||||
|
||||
|
@ -75,8 +84,8 @@ LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT
|
|||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
|
||||
python3 -m venv venv
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3.9-venv'
|
||||
python3.9 -m venv venv
|
||||
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
# create the data folder and the .env file
|
||||
|
@ -106,7 +115,7 @@ docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.en
|
|||
|
||||
## Option 5: Fly.io
|
||||
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNBits for free on Fly.io for personal use.
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
|
||||
|
||||
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
|
||||
|
||||
|
@ -169,7 +178,7 @@ kill_timeout = 30
|
|||
...
|
||||
```
|
||||
|
||||
Next, create a volume to store the sqlite database for LNBits. Be sure to choose the same region for the volume that you chose earlier.
|
||||
Next, create a volume to store the sqlite database for LNbits. Be sure to choose the same region for the volume that you chose earlier.
|
||||
|
||||
```
|
||||
fly volumes create lnbits_data --size 1
|
||||
|
|
|
@ -1,38 +1,3 @@
|
|||
import asyncio
|
||||
|
||||
import uvloop
|
||||
from loguru import logger
|
||||
from starlette.requests import Request
|
||||
|
||||
from .commands import migrate_databases
|
||||
from .settings import (
|
||||
DEBUG,
|
||||
HOST,
|
||||
LNBITS_COMMIT,
|
||||
LNBITS_DATA_FOLDER,
|
||||
LNBITS_DATABASE_URL,
|
||||
LNBITS_SITE_TITLE,
|
||||
PORT,
|
||||
WALLET,
|
||||
)
|
||||
|
||||
uvloop.install()
|
||||
|
||||
asyncio.create_task(migrate_databases())
|
||||
|
||||
from .app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
logger.info("Starting LNbits")
|
||||
logger.info(f"Host: {HOST}")
|
||||
logger.info(f"Port: {PORT}")
|
||||
logger.info(f"Debug: {DEBUG}")
|
||||
logger.info(f"Site title: {LNBITS_SITE_TITLE}")
|
||||
logger.info(f"Funding source: {WALLET.__class__.__name__}")
|
||||
logger.info(
|
||||
f"Database: {'PostgreSQL' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('postgres://') else 'CockroachDB' if LNBITS_DATABASE_URL and LNBITS_DATABASE_URL.startswith('cockroachdb://') else 'SQLite'}"
|
||||
)
|
||||
logger.info(f"Data folder: {LNBITS_DATA_FOLDER}")
|
||||
logger.info(f"Git version: {LNBITS_COMMIT}")
|
||||
# logger.info(f"Service fee: {SERVICE_FEE}")
|
||||
|
|
214
lnbits/app.py
214
lnbits/app.py
|
@ -4,21 +4,22 @@ import logging
|
|||
import signal
|
||||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.exceptions import HTTPException, RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from loguru import logger
|
||||
|
||||
import lnbits.settings
|
||||
from lnbits.core.tasks import register_task_listeners
|
||||
from lnbits.settings import get_wallet_class, set_wallet_class, settings
|
||||
|
||||
from .commands import migrate_databases
|
||||
from .core import core_app
|
||||
from .core.services import check_admin_settings
|
||||
from .core.views.generic import core_html_routes
|
||||
from .helpers import (
|
||||
get_css_vendored,
|
||||
|
@ -28,7 +29,6 @@ from .helpers import (
|
|||
url_for_vendored,
|
||||
)
|
||||
from .requestvars import g
|
||||
from .settings import WALLET
|
||||
from .tasks import (
|
||||
catch_everything_and_restart,
|
||||
check_pending_payments,
|
||||
|
@ -38,10 +38,8 @@ from .tasks import (
|
|||
)
|
||||
|
||||
|
||||
def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||
"""Create application factory.
|
||||
:param config_object: The configuration object to use.
|
||||
"""
|
||||
def create_app() -> FastAPI:
|
||||
|
||||
configure_logger()
|
||||
|
||||
app = FastAPI(
|
||||
|
@ -49,9 +47,10 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
"url": "https://raw.githubusercontent.com/lnbits/lnbits-legend/main/LICENSE",
|
||||
"url": "https://raw.githubusercontent.com/lnbits/lnbits/main/LICENSE",
|
||||
},
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||
app.mount(
|
||||
"/core/static",
|
||||
|
@ -59,40 +58,15 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
name="core_static",
|
||||
)
|
||||
|
||||
origins = ["*"]
|
||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"]
|
||||
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
|
||||
)
|
||||
|
||||
g().config = lnbits.settings
|
||||
g().base_url = f"http://{lnbits.settings.HOST}:{lnbits.settings.PORT}"
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": exc.errors()},
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
check_funding_source(app)
|
||||
register_startup(app)
|
||||
register_assets(app)
|
||||
register_routes(app)
|
||||
register_async_tasks(app)
|
||||
|
@ -101,33 +75,34 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
return app
|
||||
|
||||
|
||||
def check_funding_source(app: FastAPI) -> None:
|
||||
@app.on_event("startup")
|
||||
async def check_wallet_status():
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
async def check_funding_source() -> None:
|
||||
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug(f"SIGINT received, terminating LNbits.")
|
||||
sys.exit(1)
|
||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
while True:
|
||||
try:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
break
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
logger.info("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
logger.success(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
)
|
||||
def signal_handler(signal, frame):
|
||||
logger.debug(f"SIGINT received, terminating LNbits.")
|
||||
sys.exit(1)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
while True:
|
||||
try:
|
||||
error_message, balance = await WALLET.status()
|
||||
if not error_message:
|
||||
break
|
||||
logger.error(
|
||||
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
except:
|
||||
pass
|
||||
logger.info("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
logger.info(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
)
|
||||
|
||||
|
||||
def register_routes(app: FastAPI) -> None:
|
||||
|
@ -158,12 +133,59 @@ def register_routes(app: FastAPI) -> None:
|
|||
)
|
||||
|
||||
|
||||
def register_startup(app: FastAPI):
|
||||
@app.on_event("startup")
|
||||
async def lnbits_startup():
|
||||
|
||||
try:
|
||||
# 1. wait till migration is done
|
||||
await migrate_databases()
|
||||
|
||||
# 2. setup admin settings
|
||||
await check_admin_settings()
|
||||
|
||||
log_server_info()
|
||||
|
||||
# 3. initialize WALLET
|
||||
set_wallet_class()
|
||||
|
||||
# 4. initialize funding source
|
||||
await check_funding_source()
|
||||
except Exception as e:
|
||||
logger.error(str(e))
|
||||
raise ImportError("Failed to run 'startup' event.")
|
||||
|
||||
|
||||
def log_server_info():
|
||||
logger.info("Starting LNbits")
|
||||
logger.info(f"Host: {settings.host}")
|
||||
logger.info(f"Port: {settings.port}")
|
||||
logger.info(f"Debug: {settings.debug}")
|
||||
logger.info(f"Site title: {settings.lnbits_site_title}")
|
||||
logger.info(f"Funding source: {settings.lnbits_backend_wallet_class}")
|
||||
logger.info(f"Data folder: {settings.lnbits_data_folder}")
|
||||
logger.info(f"Git version: {settings.lnbits_commit}")
|
||||
logger.info(f"Database: {get_db_vendor_name()}")
|
||||
logger.info(f"Service fee: {settings.lnbits_service_fee}")
|
||||
|
||||
|
||||
def get_db_vendor_name():
|
||||
db_url = settings.lnbits_database_url
|
||||
return (
|
||||
"PostgreSQL"
|
||||
if db_url and db_url.startswith("postgres://")
|
||||
else "CockroachDB"
|
||||
if db_url and db_url.startswith("cockroachdb://")
|
||||
else "SQLite"
|
||||
)
|
||||
|
||||
|
||||
def register_assets(app: FastAPI):
|
||||
"""Serve each vendored asset separately or a bundle."""
|
||||
|
||||
@app.on_event("startup")
|
||||
async def vendored_assets_variable():
|
||||
if g().config.DEBUG:
|
||||
if settings.debug:
|
||||
g().VENDORED_JS = map(url_for_vendored, get_js_vendored())
|
||||
g().VENDORED_CSS = map(url_for_vendored, get_css_vendored())
|
||||
else:
|
||||
|
@ -192,12 +214,33 @@ def register_async_tasks(app):
|
|||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
@app.exception_handler(Exception)
|
||||
async def basic_error(request: Request, err):
|
||||
logger.error("handled error", traceback.format_exc())
|
||||
logger.error("ERROR:", err)
|
||||
async def exception_handler(request: Request, exc: Exception):
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, err, tb)
|
||||
exc = traceback.format_exc()
|
||||
traceback.print_exception(etype, exc, tb)
|
||||
logger.error(f"Exception: {str(exc)}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": f"Error: {str(exc)}"}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
):
|
||||
logger.error(f"RequestValidationError: {str(exc)}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
|
@ -205,18 +248,43 @@ def register_exception_handlers(app: FastAPI):
|
|||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": err}
|
||||
"error.html",
|
||||
{"request": request, "err": f"Error: {str(exc)}"},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": err},
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
|
||||
# Only the browser sends "text/html" request
|
||||
# not fail proof, but everything else get's a JSON response
|
||||
|
||||
if (
|
||||
request.headers
|
||||
and "accept" in request.headers
|
||||
and "text/html" in request.headers["accept"]
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{
|
||||
"request": request,
|
||||
"err": f"HTTP Error {exc.status_code}: {exc.detail}",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
)
|
||||
|
||||
|
||||
def configure_logger() -> None:
|
||||
logger.remove()
|
||||
log_level: str = "DEBUG" if lnbits.settings.DEBUG else "INFO"
|
||||
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||
formatter = Formatter()
|
||||
logger.add(sys.stderr, level=log_level, format=formatter.format)
|
||||
|
||||
|
@ -228,7 +296,7 @@ class Formatter:
|
|||
def __init__(self):
|
||||
self.padding = 0
|
||||
self.minimal_fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | <level>{message}</level>\n"
|
||||
if lnbits.settings.DEBUG:
|
||||
if settings.debug:
|
||||
self.fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level: <4}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>\n"
|
||||
else:
|
||||
self.fmt: str = self.minimal_fmt
|
||||
|
|
|
@ -7,6 +7,8 @@ import warnings
|
|||
import click
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .core import db as core_db
|
||||
from .core import migrations as core_migrations
|
||||
from .db import COCKROACH, POSTGRES, SQLITE
|
||||
|
@ -16,7 +18,6 @@ from .helpers import (
|
|||
get_valid_extensions,
|
||||
url_for_vendored,
|
||||
)
|
||||
from .settings import LNBITS_PATH
|
||||
|
||||
|
||||
@click.command("migrate")
|
||||
|
@ -35,15 +36,17 @@ def transpile_scss():
|
|||
warnings.simplefilter("ignore")
|
||||
from scss.compiler import compile_string # type: ignore
|
||||
|
||||
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
|
||||
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
|
||||
with open(os.path.join(settings.lnbits_path, "static/scss/base.scss")) as scss:
|
||||
with open(
|
||||
os.path.join(settings.lnbits_path, "static/css/base.css"), "w"
|
||||
) as css:
|
||||
css.write(compile_string(scss.read()))
|
||||
|
||||
|
||||
def bundle_vendored():
|
||||
for getfiles, outputpath in [
|
||||
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
|
||||
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
|
||||
(get_js_vendored, os.path.join(settings.lnbits_path, "static/bundle.js")),
|
||||
(get_css_vendored, os.path.join(settings.lnbits_path, "static/bundle.css")),
|
||||
]:
|
||||
output = ""
|
||||
for path in getfiles():
|
||||
|
|
|
@ -6,6 +6,7 @@ db = Database("database")
|
|||
|
||||
core_app: APIRouter = APIRouter()
|
||||
|
||||
from .views.admin_api import * # noqa
|
||||
from .views.api import * # noqa
|
||||
from .views.generic import * # noqa
|
||||
from .views.public_api import * # noqa
|
||||
|
|
|
@ -4,11 +4,9 @@ from typing import Any, Dict, List, Optional
|
|||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import COCKROACH, POSTGRES, Connection
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME, LNBITS_ADMIN_USERS
|
||||
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
|
||||
|
||||
from . import db
|
||||
from .models import BalanceCheck, Payment, User, Wallet
|
||||
|
@ -63,9 +61,8 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[
|
|||
email=user["email"],
|
||||
extensions=[e[0] for e in extensions],
|
||||
wallets=[Wallet(**w) for w in wallets],
|
||||
admin=user["id"] in [x.strip() for x in LNBITS_ADMIN_USERS]
|
||||
if LNBITS_ADMIN_USERS
|
||||
else False,
|
||||
admin=user["id"] == settings.super_user
|
||||
or user["id"] in settings.lnbits_admin_users,
|
||||
)
|
||||
|
||||
|
||||
|
@ -99,7 +96,7 @@ async def create_wallet(
|
|||
""",
|
||||
(
|
||||
wallet_id,
|
||||
wallet_name or DEFAULT_WALLET_NAME,
|
||||
wallet_name or settings.lnbits_default_wallet_name,
|
||||
user_id,
|
||||
uuid4().hex,
|
||||
uuid4().hex,
|
||||
|
@ -232,8 +229,8 @@ async def get_wallet_payment(
|
|||
async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: int = 5):
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE pending = 'false'
|
||||
SELECT * FROM apipayments
|
||||
WHERE pending = 'false'
|
||||
AND extra LIKE ?
|
||||
AND extra LIKE ?
|
||||
ORDER BY time DESC LIMIT {limit}
|
||||
|
@ -339,37 +336,14 @@ async def delete_expired_invoices(
|
|||
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
|
||||
"""
|
||||
)
|
||||
|
||||
# then we delete all expired invoices, checking one by one
|
||||
rows = await (conn or db).fetchall(
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
SELECT bolt11
|
||||
FROM apipayments
|
||||
WHERE pending = true
|
||||
AND bolt11 IS NOT NULL
|
||||
AND amount > 0 AND time < {db.timestamp_now} - {db.interval_seconds(86400)}
|
||||
DELETE FROM apipayments
|
||||
WHERE pending = true AND amount > 0
|
||||
AND expiry < {db.timestamp_now}
|
||||
"""
|
||||
)
|
||||
logger.debug(f"Checking expiry of {len(rows)} invoices")
|
||||
for i, (payment_request,) in enumerate(rows):
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
except:
|
||||
continue
|
||||
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration_date > datetime.datetime.utcnow():
|
||||
continue
|
||||
logger.debug(
|
||||
f"Deleting expired invoice {i}/{len(rows)}: {invoice.payment_hash} (expired: {expiration_date})"
|
||||
)
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM apipayments
|
||||
WHERE pending = true AND hash = ?
|
||||
""",
|
||||
(invoice.payment_hash,),
|
||||
)
|
||||
|
||||
|
||||
# payments
|
||||
|
@ -396,12 +370,19 @@ async def create_payment(
|
|||
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
# assert previous_payment is None, "Payment already exists"
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
except:
|
||||
# assume maximum bolt11 expiry of 31 days to be on the safe side
|
||||
expiration_date = datetime.datetime.now() + datetime.timedelta(days=31)
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO apipayments
|
||||
(wallet, checking_id, bolt11, hash, preimage,
|
||||
amount, pending, memo, fee, extra, webhook)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
amount, pending, memo, fee, extra, webhook, expiry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
|
@ -417,6 +398,7 @@ async def create_payment(
|
|||
if extra and extra != {} and type(extra) is dict
|
||||
else None,
|
||||
webhook,
|
||||
db.datetime_to_timestamp(expiration_date),
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -565,3 +547,48 @@ async def get_balance_notify(
|
|||
(wallet_id,),
|
||||
)
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
# admin
|
||||
# --------
|
||||
|
||||
|
||||
async def get_super_settings() -> Optional[SuperSettings]:
|
||||
row = await db.fetchone("SELECT * FROM settings")
|
||||
if not row:
|
||||
return None
|
||||
editable_settings = json.loads(row["editable_settings"])
|
||||
return SuperSettings(**{"super_user": row["super_user"], **editable_settings})
|
||||
|
||||
|
||||
async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSettings]:
|
||||
sets = await get_super_settings()
|
||||
if not sets:
|
||||
return None
|
||||
row_dict = dict(sets)
|
||||
row_dict.pop("super_user")
|
||||
admin_settings = AdminSettings(
|
||||
super_user=is_super_user,
|
||||
lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
|
||||
**row_dict,
|
||||
)
|
||||
return admin_settings
|
||||
|
||||
|
||||
async def delete_admin_settings():
|
||||
await db.execute("DELETE FROM settings")
|
||||
|
||||
|
||||
async def update_admin_settings(data: EditableSettings):
|
||||
await db.execute(f"UPDATE settings SET editable_settings = ?", (json.dumps(data),))
|
||||
|
||||
|
||||
async def update_super_user(super_user: str):
|
||||
await db.execute("UPDATE settings SET super_user = ?", (super_user,))
|
||||
return await get_super_settings()
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict):
|
||||
sql = f"INSERT INTO settings (super_user, editable_settings) VALUES (?, ?)"
|
||||
await db.execute(sql, (super_user, json.dumps(new_settings)))
|
||||
return await get_super_settings()
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import datetime
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy.exc import OperationalError # type: ignore
|
||||
|
||||
from lnbits import bolt11
|
||||
|
||||
|
||||
async def m000_create_migrations_table(db):
|
||||
await db.execute(
|
||||
|
@ -188,3 +193,79 @@ async def m005_balance_check_balance_notify(db):
|
|||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m006_add_invoice_expiry_to_apipayments(db):
|
||||
"""
|
||||
Adds invoice expiry column to apipayments.
|
||||
"""
|
||||
try:
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN expiry TIMESTAMP")
|
||||
except OperationalError:
|
||||
pass
|
||||
|
||||
|
||||
async def m007_set_invoice_expiries(db):
|
||||
"""
|
||||
Precomputes invoice expiry for existing pending incoming payments.
|
||||
"""
|
||||
try:
|
||||
rows = await (
|
||||
await db.execute(
|
||||
f"""
|
||||
SELECT bolt11, checking_id
|
||||
FROM apipayments
|
||||
WHERE pending = true
|
||||
AND amount > 0
|
||||
AND bolt11 IS NOT NULL
|
||||
AND expiry IS NULL
|
||||
AND time < {db.timestamp_now}
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
if len(rows):
|
||||
logger.info(f"Mirgraion: Checking expiry of {len(rows)} invoices")
|
||||
for i, (
|
||||
payment_request,
|
||||
checking_id,
|
||||
) in enumerate(rows):
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
if invoice.expiry is None:
|
||||
continue
|
||||
|
||||
expiration_date = datetime.datetime.fromtimestamp(
|
||||
invoice.date + invoice.expiry
|
||||
)
|
||||
logger.info(
|
||||
f"Mirgraion: {i+1}/{len(rows)} setting expiry of invoice {invoice.payment_hash} to {expiration_date}"
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET expiry = ?
|
||||
WHERE checking_id = ? AND amount > 0
|
||||
""",
|
||||
(
|
||||
db.datetime_to_timestamp(expiration_date),
|
||||
checking_id,
|
||||
),
|
||||
)
|
||||
except:
|
||||
continue
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
# run twice in some environments.
|
||||
# catching errors like this won't be necessary in anymore now that we
|
||||
# keep track of db versions so no migration ever runs twice.
|
||||
pass
|
||||
|
||||
|
||||
async def m008_create_admin_settings_table(db):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
super_user TEXT,
|
||||
editable_settings TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from sqlite3 import Row
|
||||
from typing import Dict, List, NamedTuple, Optional
|
||||
|
||||
from ecdsa import SECP256k1, SigningKey # type: ignore
|
||||
from fastapi import Query
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Extra, validator
|
||||
|
||||
from lnbits.db import Connection
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.settings import WALLET
|
||||
from lnbits.settings import get_wallet_class
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
|
||||
|
||||
|
@ -63,6 +66,7 @@ class User(BaseModel):
|
|||
wallets: List[Wallet] = []
|
||||
password: Optional[str] = None
|
||||
admin: bool = False
|
||||
super_user: bool = False
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> List[str]:
|
||||
|
@ -83,6 +87,7 @@ class Payment(BaseModel):
|
|||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
expiry: Optional[float]
|
||||
extra: Optional[Dict] = {}
|
||||
wallet_id: str
|
||||
webhook: Optional[str]
|
||||
|
@ -101,6 +106,7 @@ class Payment(BaseModel):
|
|||
fee=row["fee"],
|
||||
memo=row["memo"],
|
||||
time=row["time"],
|
||||
expiry=row["expiry"],
|
||||
wallet_id=row["wallet"],
|
||||
webhook=row["webhook"],
|
||||
webhook_status=row["webhook_status"],
|
||||
|
@ -128,6 +134,10 @@ class Payment(BaseModel):
|
|||
def is_out(self) -> bool:
|
||||
return self.amount < 0
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
return self.expiry < time.time() if self.expiry else False
|
||||
|
||||
@property
|
||||
def is_uncheckable(self) -> bool:
|
||||
return self.checking_id.startswith("internal_")
|
||||
|
@ -163,6 +173,7 @@ class Payment(BaseModel):
|
|||
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
|
||||
)
|
||||
|
||||
WALLET = get_wallet_class()
|
||||
if self.is_out:
|
||||
status = await WALLET.get_payment_status(self.checking_id)
|
||||
else:
|
||||
|
@ -170,7 +181,13 @@ class Payment(BaseModel):
|
|||
|
||||
logger.debug(f"Status: {status}")
|
||||
|
||||
if self.is_out and status.failed:
|
||||
if self.is_in and status.pending and self.is_expired and self.expiry:
|
||||
expiration_date = datetime.datetime.fromtimestamp(self.expiry)
|
||||
logger.debug(
|
||||
f"Deleting expired incoming pending payment {self.checking_id}: expired {expiration_date}"
|
||||
)
|
||||
await self.delete(conn)
|
||||
elif self.is_out and status.failed:
|
||||
logger.warning(
|
||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||
)
|
||||
|
|
|
@ -2,11 +2,11 @@ import asyncio
|
|||
import json
|
||||
from binascii import unhexlify
|
||||
from io import BytesIO
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, WebSocket
|
||||
from lnurl import LnurlErrorResponse
|
||||
from lnurl import decode as decode_lnurl # type: ignore
|
||||
from loguru import logger
|
||||
|
@ -21,18 +21,31 @@ from lnbits.decorators import (
|
|||
)
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import FAKE_WALLET, RESERVE_FEE_MIN, RESERVE_FEE_PERCENT, WALLET
|
||||
from lnbits.settings import (
|
||||
FAKE_WALLET,
|
||||
EditableSettings,
|
||||
get_wallet_class,
|
||||
readonly_variables,
|
||||
send_admin_user_to_saas,
|
||||
settings,
|
||||
)
|
||||
from lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||
|
||||
from . import db
|
||||
from .crud import (
|
||||
check_internal,
|
||||
create_account,
|
||||
create_admin_settings,
|
||||
create_payment,
|
||||
create_wallet,
|
||||
delete_wallet_payment,
|
||||
get_account,
|
||||
get_super_settings,
|
||||
get_wallet,
|
||||
get_wallet_payment,
|
||||
update_payment_details,
|
||||
update_payment_status,
|
||||
update_super_user,
|
||||
)
|
||||
from .models import Payment
|
||||
|
||||
|
@ -65,7 +78,7 @@ async def create_invoice(
|
|||
invoice_memo = None if description_hash else memo
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
wallet = FAKE_WALLET if internal else WALLET
|
||||
wallet = FAKE_WALLET if internal else get_wallet_class()
|
||||
|
||||
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||
amount=amount,
|
||||
|
@ -193,6 +206,7 @@ async def pay_invoice(
|
|||
else:
|
||||
logger.debug(f"backend: sending payment {temp_id}")
|
||||
# actually pay the external invoice
|
||||
WALLET = get_wallet_class()
|
||||
payment: PaymentResponse = await WALLET.pay_invoice(
|
||||
payment_request, fee_reserve_msat
|
||||
)
|
||||
|
@ -381,4 +395,110 @@ async def check_transaction_status(
|
|||
|
||||
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
|
||||
reserve_min = settings.lnbits_reserve_fee_min
|
||||
reserve_percent = settings.lnbits_reserve_fee_percent
|
||||
return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0))
|
||||
|
||||
|
||||
async def update_wallet_balance(wallet_id: str, amount: int):
|
||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||
payment = await create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=internal_id,
|
||||
payment_request="admin_internal",
|
||||
payment_hash="admin_internal",
|
||||
amount=amount * 1000,
|
||||
memo="Admin top up",
|
||||
pending=False,
|
||||
)
|
||||
# manually send this for now
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(internal_id)
|
||||
return payment
|
||||
|
||||
|
||||
async def check_admin_settings():
|
||||
if settings.lnbits_admin_ui:
|
||||
settings_db = await get_super_settings()
|
||||
if not settings_db:
|
||||
# create new settings if table is empty
|
||||
logger.warning("Settings DB empty. Inserting default settings.")
|
||||
settings_db = await init_admin_settings(settings.super_user)
|
||||
logger.warning("Initialized settings from enviroment variables.")
|
||||
|
||||
if settings.super_user and settings.super_user != settings_db.super_user:
|
||||
# .env super_user overwrites DB super_user
|
||||
settings_db = await update_super_user(settings.super_user)
|
||||
|
||||
update_cached_settings(settings_db.dict())
|
||||
|
||||
# printing settings for debugging
|
||||
logger.debug(f"Admin settings:")
|
||||
for key, value in settings.dict(exclude_none=True).items():
|
||||
logger.debug(f"{key}: {value}")
|
||||
|
||||
http = "https" if settings.lnbits_force_https else "http"
|
||||
admin_url = (
|
||||
f"{http}://{settings.host}:{settings.port}/wallet?usr={settings.super_user}"
|
||||
)
|
||||
logger.success(f"✔️ Access super user account at: {admin_url}")
|
||||
|
||||
# callback for saas
|
||||
if (
|
||||
settings.lnbits_saas_callback
|
||||
and settings.lnbits_saas_secret
|
||||
and settings.lnbits_saas_instance_id
|
||||
):
|
||||
send_admin_user_to_saas()
|
||||
|
||||
|
||||
def update_cached_settings(sets_dict: dict):
|
||||
for key, value in sets_dict.items():
|
||||
if not key in readonly_variables:
|
||||
try:
|
||||
setattr(settings, key, value)
|
||||
except:
|
||||
logger.error(f"error overriding setting: {key}, value: {value}")
|
||||
if "super_user" in sets_dict:
|
||||
setattr(settings, "super_user", sets_dict["super_user"])
|
||||
|
||||
|
||||
async def init_admin_settings(super_user: str = None):
|
||||
account = None
|
||||
if super_user:
|
||||
account = await get_account(super_user)
|
||||
if not account:
|
||||
account = await create_account()
|
||||
super_user = account.id
|
||||
if not account.wallets or len(account.wallets) == 0:
|
||||
await create_wallet(user_id=account.id)
|
||||
|
||||
editable_settings = EditableSettings.from_dict(settings.dict())
|
||||
|
||||
return await create_admin_settings(account.id, editable_settings.dict())
|
||||
|
||||
|
||||
class WebsocketConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
logger.debug(websocket)
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def send_data(self, message: str, item_id: str):
|
||||
for connection in self.active_connections:
|
||||
if connection.path_params["item_id"] == item_id:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
websocketManager = WebsocketConnectionManager()
|
||||
|
||||
|
||||
async def websocketUpdater(item_id, data):
|
||||
return await websocketManager.send_data(f"{data}", item_id)
|
||||
|
|
|
@ -259,25 +259,30 @@ new Vue({
|
|||
this.parse.camera.show = false
|
||||
},
|
||||
updateBalance: function (credit) {
|
||||
if (LNBITS_DENOMINATION != 'sats') {
|
||||
credit = credit * 100
|
||||
}
|
||||
LNbits.api
|
||||
.request('PUT', '/api/v1/wallet/balance/' + credit, this.g.wallet.inkey)
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
.then(response => {
|
||||
let data = response.data
|
||||
if (data.status === 'ERROR') {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: `Failed to update.`
|
||||
})
|
||||
return
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/topup/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
amount: credit,
|
||||
id: this.g.user.wallets[0].id
|
||||
}
|
||||
this.balance = this.balance + data.balance
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Added ' +
|
||||
credit +
|
||||
' sats to ' +
|
||||
this.g.user.wallets[0].id,
|
||||
icon: null
|
||||
})
|
||||
this.balance += parseInt(credit)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeReceiveDialog: function () {
|
||||
|
|
95
lnbits/core/templates/admin/_tab_funding.html
Normal file
95
lnbits/core/templates/admin/_tab_funding.html
Normal file
|
@ -0,0 +1,95 @@
|
|||
<q-tab-panel name="funding">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">Wallets Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>Funding Source Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li>Funding Source: {{settings.lnbits_backend_wallet_class}}</li>
|
||||
<li>Balance: {{balance / 1000}} sats</li>
|
||||
{%endraw%}
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<p>Active Funding<small> (Requires server restart)</small></p>
|
||||
<q-select
|
||||
:disable="!isSuperUser"
|
||||
filled
|
||||
v-model="formData.lnbits_backend_wallet_class"
|
||||
hint="Select the active funding wallet"
|
||||
:options="settings.lnbits_allowed_funding_sources"
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="col-12">
|
||||
<p>Fee reserve</p>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
v-model="formData.lnbits_reserve_fee_min"
|
||||
label="Reserve fee in msats"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-input
|
||||
type="number"
|
||||
filled
|
||||
name="lnbits_reserve_fee_percent"
|
||||
v-model="formData.lnbits_reserve_fee_percent"
|
||||
label="Reserve fee in percent"
|
||||
step="0.1"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSuperUser">
|
||||
<p class="q-my-md">
|
||||
Funding Sources<small> (Requires server restart)</small>
|
||||
</p>
|
||||
<q-list
|
||||
v-for="(fund, idx) in settings.lnbits_allowed_funding_sources"
|
||||
:key="idx"
|
||||
>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
icon="payments"
|
||||
:label="fund"
|
||||
v-if="funding_sources.get(fund)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section
|
||||
v-for="([key, prop], i) in Object.entries(funding_sources.get(fund))"
|
||||
:key="i"
|
||||
>
|
||||
<q-input
|
||||
dense
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData[key]"
|
||||
:label="prop.label"
|
||||
class="q-pr-md"
|
||||
:hint="prop.hint"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
74
lnbits/core/templates/admin/_tab_server.html
Normal file
74
lnbits/core/templates/admin/_tab_server.html
Normal file
|
@ -0,0 +1,74 @@
|
|||
<q-tab-panel name="server">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">Server Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<p>Server Info</p>
|
||||
<ul>
|
||||
{%raw%}
|
||||
<li v-if="settings.lnbits_data_folder">
|
||||
SQlite: {{settings.lnbits_data_folder}}
|
||||
</li>
|
||||
<li v-if="settings.lnbits_database_url">
|
||||
Postgres: {{settings.lnbits_database_url}}
|
||||
</li>
|
||||
{%endraw%}
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Service Fee</p>
|
||||
<q-input
|
||||
filled
|
||||
type="number"
|
||||
v-model.number="formData.lnbits_service_fee"
|
||||
label="Service fee (%)"
|
||||
step="0.1"
|
||||
hint="Fee charged per tx (%)"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Miscelaneous</p>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Force HTTPS</q-item-label>
|
||||
<q-item-label caption>Prefer secure URLs</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_force_https"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item tag="label" v-ripple>
|
||||
<q-item-section>
|
||||
<q-item-label>Hide API</q-item-label>
|
||||
<q-item-label caption
|
||||
>Hides wallet api, extensions can choose to honor</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
<q-item-section avatar>
|
||||
<q-toggle
|
||||
size="md"
|
||||
v-model="formData.lnbits_hide_api"
|
||||
checked-icon="check"
|
||||
color="green"
|
||||
unchecked-icon="clear"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
117
lnbits/core/templates/admin/_tab_theme.html
Normal file
117
lnbits/core/templates/admin/_tab_theme.html
Normal file
|
@ -0,0 +1,117 @@
|
|||
<q-tab-panel name="theme">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">UI Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Site Title</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_site_title"
|
||||
label="Site title"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Site Tagline</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_site_tagline"
|
||||
label="Site tagline"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Site Description</p>
|
||||
<q-input
|
||||
v-model="formData.lnbits_site_description"
|
||||
filled
|
||||
type="textarea"
|
||||
hint="Use plain text or raw HTML"
|
||||
/>
|
||||
</div>
|
||||
<br />
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Default Wallet Name</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_default_wallet_name"
|
||||
label="LNbits wallet"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Denomination</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_denomination"
|
||||
label="sats"
|
||||
hint="The name for the FakeWallet token"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Themes</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_theme_options"
|
||||
multiple
|
||||
hint="Choose themes available for users"
|
||||
:options="lnbits_theme_options"
|
||||
label="Themes"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Custom Logo</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_custom_logo"
|
||||
label="https://example.com/image.png"
|
||||
hint="URL to logo image"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Ad Space Title</p>
|
||||
<q-input
|
||||
filled
|
||||
type="text"
|
||||
v-model="formData.lnbits_ad_space_title"
|
||||
label="Supported by"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Advertisement Slots</p>
|
||||
<q-input
|
||||
class="q-mb-md"
|
||||
filled
|
||||
v-model="formData.lnbits_ad_space"
|
||||
type="text"
|
||||
label="url;img_light_url;img_dark_url, url..."
|
||||
hint="Ad url and image filepaths in CSV format, extensions can choose to honor"
|
||||
>
|
||||
</q-input>
|
||||
<q-toggle
|
||||
v-model="formData.lnbits_ad_space_enabled"
|
||||
:label="formData.lnbits_ad_space_enabled ? 'Ads enabled' : 'Ads disabled'"
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
88
lnbits/core/templates/admin/_tab_users.html
Normal file
88
lnbits/core/templates/admin/_tab_users.html
Normal file
|
@ -0,0 +1,88 @@
|
|||
<q-tab-panel name="users">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h6 class="q-my-none">User Management</h6>
|
||||
<br />
|
||||
<div>
|
||||
<p>Admin Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddAdmin"
|
||||
@keydown.enter="addAdminUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Users with admin privileges"
|
||||
>
|
||||
<q-btn @click="addAdminUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{%raw%}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_admin_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAdminUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ user }}
|
||||
</q-chip>
|
||||
{%endraw%}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div>
|
||||
<p>Allowed Users</p>
|
||||
<q-input
|
||||
filled
|
||||
v-model="formAddUser"
|
||||
@keydown.enter="addAllowedUser"
|
||||
type="text"
|
||||
label="User ID"
|
||||
hint="Only these users can use LNbits"
|
||||
>
|
||||
<q-btn @click="addAllowedUser" dense flat icon="add"></q-btn>
|
||||
</q-input>
|
||||
<div>
|
||||
{% raw %}
|
||||
<q-chip
|
||||
v-for="user in formData.lnbits_allowed_users"
|
||||
:key="user"
|
||||
removable
|
||||
@remove="removeAllowedUser(user)"
|
||||
color="primary"
|
||||
text-color="white"
|
||||
>
|
||||
{{ user }}
|
||||
</q-chip>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Admin Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_admin_extensions"
|
||||
multiple
|
||||
hint="Extensions only user with admin privileges can use"
|
||||
label="Admin extensions"
|
||||
:options="g.extensions.map(e => e.name)"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<p>Disabled Extensions</p>
|
||||
<q-select
|
||||
filled
|
||||
v-model="formData.lnbits_disabled_extensions"
|
||||
:options="g.extensions.map(e => e.name)"
|
||||
multiple
|
||||
hint="Disable extensions *amilk disabled by default as resource heavy"
|
||||
label="Disable extensions"
|
||||
></q-select>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
529
lnbits/core/templates/admin/index.html
Normal file
529
lnbits/core/templates/admin/index.html
Normal file
|
@ -0,0 +1,529 @@
|
|||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-my-md">
|
||||
<q-btn
|
||||
label="Save"
|
||||
color="primary"
|
||||
@click="updateSettings"
|
||||
:disabled="!checkChanges"
|
||||
>
|
||||
<q-tooltip v-if="checkChanges"> Save your changes </q-tooltip>
|
||||
<q-badge
|
||||
v-if="checkChanges"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
label="Restart server"
|
||||
color="primary"
|
||||
@click="restartServer"
|
||||
>
|
||||
<q-tooltip v-if="needsRestart">
|
||||
Restart the server for changes to take effect
|
||||
</q-tooltip>
|
||||
<q-badge
|
||||
v-if="needsRestart"
|
||||
color="red"
|
||||
rounded
|
||||
floating
|
||||
style="padding: 6px; border-radius: 6px"
|
||||
/>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="isSuperUser"
|
||||
label="Topup"
|
||||
color="primary"
|
||||
@click="topUpDialog.show = true"
|
||||
>
|
||||
<q-tooltip> Add funds to a wallet. </q-tooltip>
|
||||
</q-btn>
|
||||
<!-- <q-btn
|
||||
label="Download Database Backup"
|
||||
flat
|
||||
@click="downloadBackup"
|
||||
></q-btn> -->
|
||||
<q-btn
|
||||
flat
|
||||
v-if="isSuperUser"
|
||||
label="Reset to defaults"
|
||||
color="primary"
|
||||
@click="deleteSettings"
|
||||
class="float-right"
|
||||
>
|
||||
<q-tooltip> Delete all settings and reset to defaults. </q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<div class="q-pa-md">
|
||||
<div class="q-gutter-y-md">
|
||||
<q-tabs v-model="tab" active-color="primary" align="justify">
|
||||
<q-tab
|
||||
name="funding"
|
||||
label="Funding"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="users"
|
||||
label="Users"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="server"
|
||||
label="Server"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
<q-tab
|
||||
name="theme"
|
||||
label="Theme"
|
||||
@update="val => tab = val.name"
|
||||
></q-tab>
|
||||
</q-tabs>
|
||||
</div>
|
||||
</div>
|
||||
<q-form name="settings_form" id="settings_form">
|
||||
<q-tab-panels v-model="tab" animated>
|
||||
{% include "admin/_tab_funding.html" %} {% include
|
||||
"admin/_tab_users.html" %} {% include "admin/_tab_server.html" %} {%
|
||||
include "admin/_tab_theme.html" %}
|
||||
</q-tab-panels>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-dialog v-if="isSuperUser" v-model="topUpDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<p>TopUp a wallet</p>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="text"
|
||||
filled
|
||||
v-model="wallet.id"
|
||||
label="Wallet ID"
|
||||
hint="Use the wallet ID to topup any wallet"
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<q-input
|
||||
dense
|
||||
type="number"
|
||||
filled
|
||||
v-model="wallet.amount"
|
||||
label="Topup amount"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn label="Topup" color="primary" @click="topupWallet"></q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
settings: {},
|
||||
lnbits_theme_options: [
|
||||
'classic',
|
||||
'bitcoin',
|
||||
'flamingo',
|
||||
'freedom',
|
||||
'mint',
|
||||
'autumn',
|
||||
'monochrome',
|
||||
'salvador'
|
||||
],
|
||||
formData: {},
|
||||
formAddAdmin: '',
|
||||
formAddUser: '',
|
||||
isSuperUser: false,
|
||||
wallet: {},
|
||||
cancel: {},
|
||||
topUpDialog: {
|
||||
show: false
|
||||
},
|
||||
tab: 'funding',
|
||||
needsRestart: false,
|
||||
funding_sources: new Map([
|
||||
['VoidWallet', null],
|
||||
[
|
||||
'FakeWallet',
|
||||
{
|
||||
fake_wallet_secret: {
|
||||
value: null,
|
||||
label: 'Secret'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'CLightningWallet',
|
||||
{
|
||||
corelightning_rpc: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LndRestWallet',
|
||||
{
|
||||
lnd_rest_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnd_rest_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
lnd_rest_macaroon: {
|
||||
value: null,
|
||||
label: 'Macaroon'
|
||||
},
|
||||
lnd_rest_macaroon_encrypted: {
|
||||
value: null,
|
||||
label: 'Encrypted Macaroon'
|
||||
},
|
||||
lnd_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
lnd_admin_macaroon: {
|
||||
value: null,
|
||||
label: 'Admin Macaroon'
|
||||
},
|
||||
lnd_invoice_macaroon: {
|
||||
value: null,
|
||||
label: 'Invoice Macaroon'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LndWallet',
|
||||
{
|
||||
lnd_grpc_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnd_grpc_cert: {
|
||||
value: null,
|
||||
label: 'Certificate'
|
||||
},
|
||||
lnd_grpc_port: {
|
||||
value: null,
|
||||
label: 'Port'
|
||||
},
|
||||
lnd_grpc_admin_macaroon: {
|
||||
value: null,
|
||||
label: 'Admin Macaroon'
|
||||
},
|
||||
lnd_grpc_invoice_macaroon: {
|
||||
value: null,
|
||||
label: 'Invoice Macaroon'
|
||||
},
|
||||
lnd_grpc_macaroon_encrypted: {
|
||||
value: null,
|
||||
label: 'Encrypted Macaroon'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LntxbotWallet',
|
||||
{
|
||||
lntxbot_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lntxbot_key: {
|
||||
value: null,
|
||||
label: 'Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LNPayWallet',
|
||||
{
|
||||
lnpay_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnpay_api_key: {
|
||||
value: null,
|
||||
label: 'API Key'
|
||||
},
|
||||
lnpay_wallet_key: {
|
||||
value: null,
|
||||
label: 'Wallet Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'EclairWallet',
|
||||
{
|
||||
eclair_url: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
eclair_pass: {
|
||||
value: null,
|
||||
label: 'Password'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LNbitsWallet',
|
||||
{
|
||||
lnbits_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lnbits_key: {
|
||||
value: null,
|
||||
label: 'Admin Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'OpenNodeWallet',
|
||||
{
|
||||
opennode_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
opennode_key: {
|
||||
value: null,
|
||||
label: 'Key'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'ClicheWallet',
|
||||
{
|
||||
cliche_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'SparkWallet',
|
||||
{
|
||||
spark_url: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
spark_token: {
|
||||
value: null,
|
||||
label: 'Token'
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
'LnTipsWallet',
|
||||
{
|
||||
lntips_api_endpoint: {
|
||||
value: null,
|
||||
label: 'Endpoint'
|
||||
},
|
||||
lntips_api_key: {
|
||||
value: null,
|
||||
label: 'API Key'
|
||||
}
|
||||
}
|
||||
]
|
||||
])
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getSettings()
|
||||
this.balance = +'{{ balance|safe }}'
|
||||
},
|
||||
computed: {
|
||||
checkChanges() {
|
||||
return !_.isEqual(this.settings, this.formData)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addAdminUser() {
|
||||
let addUser = this.formAddAdmin
|
||||
let admin_users = this.formData.lnbits_admin_users
|
||||
if (addUser && addUser.length && !admin_users.includes(addUser)) {
|
||||
//admin_users = [...admin_users, addUser]
|
||||
this.formData.lnbits_admin_users = [...admin_users, addUser]
|
||||
this.formAddAdmin = ''
|
||||
//console.log(this.checkChanges)
|
||||
}
|
||||
},
|
||||
removeAdminUser(user) {
|
||||
let admin_users = this.formData.lnbits_admin_users
|
||||
this.formData.lnbits_admin_users = admin_users.filter(u => u !== user)
|
||||
},
|
||||
addAllowedUser() {
|
||||
let addUser = this.formAddUser
|
||||
let allowed_users = this.formData.lnbits_allowed_users
|
||||
if (addUser && addUser.length && !allowed_users.includes(addUser)) {
|
||||
this.formData.lnbits_allowed_users = [...allowed_users, addUser]
|
||||
this.formAddUser = ''
|
||||
}
|
||||
},
|
||||
removeAllowedUser(user) {
|
||||
let allowed_users = this.formData.lnbits_allowed_users
|
||||
this.formData.lnbits_allowed_users = allowed_users.filter(
|
||||
u => u !== user
|
||||
)
|
||||
},
|
||||
restartServer() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Success! Restarted Server',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
topupWallet() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/topup/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
this.wallet
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Added ' +
|
||||
this.wallet.amount +
|
||||
' to ' +
|
||||
this.wallet.id,
|
||||
icon: null
|
||||
})
|
||||
this.wallet = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateFundingData() {
|
||||
this.settings.lnbits_allowed_funding_sources.map(f => {
|
||||
let opts = this.funding_sources.get(f)
|
||||
if (!opts) return
|
||||
|
||||
Object.keys(opts).forEach(e => {
|
||||
opts[e].value = this.settings[e]
|
||||
})
|
||||
})
|
||||
},
|
||||
getSettings() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.isSuperUser = response.data.super_user || false
|
||||
this.settings = response.data
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateSettings() {
|
||||
let data = _.omit(this.formData, [
|
||||
'super_user',
|
||||
'lnbits_allowed_funding_sources'
|
||||
])
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
data
|
||||
)
|
||||
.then(response => {
|
||||
this.needsRestart =
|
||||
this.settings.lnbits_backend_wallet_class !==
|
||||
this.formData.lnbits_backend_wallet_class
|
||||
this.settings = this.formData
|
||||
this.formData = _.clone(this.settings)
|
||||
this.updateFundingData()
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Success! Settings changed!',
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteSettings() {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to restore settings to default?'
|
||||
)
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/admin/api/v1/settings/?usr=' + this.g.user.id
|
||||
)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Restored settings to defaults, restart required!',
|
||||
icon: null
|
||||
})
|
||||
this.needsRestart = true
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
downloadBackup() {
|
||||
LNbits.api
|
||||
.request('GET', '/admin/api/v1/backup/?usr=' + this.g.user.id)
|
||||
.then(response => {
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message:
|
||||
'Success! Database backup request, download starts soon!',
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
|
@ -82,7 +82,7 @@
|
|||
>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>{{SITE_DESCRIPTION}}</p>
|
||||
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
|
File diff suppressed because it is too large
Load Diff
74
lnbits/core/views/admin_api.py
Normal file
74
lnbits/core/views/admin_api.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Body, Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.services import update_cached_settings, update_wallet_balance
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, EditableSettings
|
||||
|
||||
from .. import core_app
|
||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||
|
||||
|
||||
@core_app.get("/admin/api/v1/settings/")
|
||||
async def api_get_settings(
|
||||
user: User = Depends(check_admin), # type: ignore
|
||||
) -> Optional[AdminSettings]:
|
||||
admin_settings = await get_admin_settings(user.super_user)
|
||||
return admin_settings
|
||||
|
||||
|
||||
@core_app.put(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_update_settings(data: EditableSettings):
|
||||
await update_admin_settings(data)
|
||||
update_cached_settings(dict(data))
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.delete(
|
||||
"/admin/api/v1/settings/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_delete_settings() -> None:
|
||||
await delete_admin_settings()
|
||||
server_restart.set()
|
||||
|
||||
|
||||
@core_app.get(
|
||||
"/admin/api/v1/restart/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_restart_server() -> dict[str, str]:
|
||||
server_restart.set()
|
||||
return {"status": "Success"}
|
||||
|
||||
|
||||
@core_app.put(
|
||||
"/admin/api/v1/topup/",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_super_user)],
|
||||
)
|
||||
async def api_topup_balance(
|
||||
id: str = Body(...), amount: int = Body(...)
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
await get_wallet(id)
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="wallet does not exist."
|
||||
)
|
||||
|
||||
await update_wallet_balance(wallet_id=id, amount=int(amount))
|
||||
|
||||
return {"status": "Success"}
|
|
@ -6,31 +6,40 @@ import time
|
|||
import uuid
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import async_timeout
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import Depends, Header, Query, Request, Response
|
||||
from fastapi import (
|
||||
Depends,
|
||||
Header,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.params import Body
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic.fields import Field
|
||||
from sse_starlette.sse import EventSourceResponse, ServerSentEvent
|
||||
from starlette.responses import HTMLResponse, StreamingResponse
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from lnbits import bolt11, lnurl
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_admin,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE, WALLET
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
|
@ -56,6 +65,8 @@ from ..services import (
|
|||
create_invoice,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
websocketManager,
|
||||
websocketUpdater,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
|
@ -72,35 +83,6 @@ async def api_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
return {"name": wallet.wallet.name, "balance": wallet.wallet.balance_msat}
|
||||
|
||||
|
||||
@core_app.put("/api/v1/wallet/balance/{amount}")
|
||||
async def api_update_balance(
|
||||
amount: int, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
if wallet.wallet.user not in LNBITS_ADMIN_USERS:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user"
|
||||
)
|
||||
|
||||
payHash = urlsafe_short_hash()
|
||||
await create_payment(
|
||||
wallet_id=wallet.wallet.id,
|
||||
checking_id=payHash,
|
||||
payment_request="selfPay",
|
||||
payment_hash=payHash,
|
||||
amount=amount * 1000,
|
||||
memo="selfPay",
|
||||
fee=0,
|
||||
)
|
||||
await update_payment_status(checking_id=payHash, pending=False)
|
||||
updatedWallet = await get_wallet(wallet.wallet.id)
|
||||
|
||||
return {
|
||||
"id": wallet.wallet.id,
|
||||
"name": wallet.wallet.name,
|
||||
"balance": amount,
|
||||
}
|
||||
|
||||
|
||||
@core_app.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
|
@ -176,7 +158,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
|||
else:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or LNBITS_SITE_TITLE
|
||||
memo = data.memo or settings.lnbits_site_title
|
||||
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
|
@ -406,7 +388,7 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
|||
|
||||
yield dict(data=jdata, event=typ)
|
||||
except asyncio.CancelledError as e:
|
||||
logger.debug(f"CancelledError on listener {uid}: {e}")
|
||||
logger.debug(f"removing listener for wallet {uid}")
|
||||
api_invoice_listeners.pop(uid)
|
||||
task.cancel()
|
||||
return
|
||||
|
@ -676,13 +658,9 @@ async def img(request: Request, data):
|
|||
)
|
||||
|
||||
|
||||
@core_app.get("/api/v1/audit/")
|
||||
async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
if wallet.wallet.user not in LNBITS_ADMIN_USERS:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user"
|
||||
)
|
||||
|
||||
@core_app.get("/api/v1/audit/", dependencies=[Depends(check_admin)])
|
||||
async def api_auditor():
|
||||
WALLET = get_wallet_class()
|
||||
total_balance = await get_total_balance()
|
||||
error_message, node_balance = await WALLET.status()
|
||||
|
||||
|
@ -692,8 +670,39 @@ async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
node_balance, delta = None, None
|
||||
|
||||
return {
|
||||
"node_balance_msats": node_balance,
|
||||
"lnbits_balance_msats": total_balance,
|
||||
"delta_msats": delta,
|
||||
"node_balance_msats": int(node_balance),
|
||||
"lnbits_balance_msats": int(total_balance),
|
||||
"delta_msats": int(delta),
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
##################UNIVERSAL WEBSOCKET MANAGER########################
|
||||
|
||||
|
||||
@core_app.websocket("/api/v1/ws/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
websocketManager.disconnect(websocket)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/ws/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/ws/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except:
|
||||
return {"sent": False, "data": data}
|
||||
|
|
|
@ -13,15 +13,9 @@ from starlette.responses import HTMLResponse, JSONResponse
|
|||
|
||||
from lnbits.core import db
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.decorators import check_admin, check_user_exists
|
||||
from lnbits.helpers import template_renderer, url_for
|
||||
from lnbits.settings import (
|
||||
LNBITS_ADMIN_USERS,
|
||||
LNBITS_ALLOWED_USERS,
|
||||
LNBITS_CUSTOM_LOGO,
|
||||
LNBITS_SITE_TITLE,
|
||||
SERVICE_FEE,
|
||||
)
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
|
||||
from ...helpers import get_valid_extensions
|
||||
from ..crud import (
|
||||
|
@ -117,7 +111,6 @@ async def wallet(
|
|||
user_id = usr.hex if usr else None
|
||||
wallet_id = wal.hex if wal else None
|
||||
wallet_name = nme
|
||||
service_fee = int(SERVICE_FEE) if int(SERVICE_FEE) == SERVICE_FEE else SERVICE_FEE
|
||||
|
||||
if not user_id:
|
||||
user = await get_user((await create_account()).id)
|
||||
|
@ -128,11 +121,14 @@ async def wallet(
|
|||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User does not exist."}
|
||||
)
|
||||
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and user_id not in settings.lnbits_allowed_users
|
||||
):
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": "User not authorized."}
|
||||
)
|
||||
if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
|
||||
if user_id == settings.super_user or user_id in settings.lnbits_admin_users:
|
||||
user.admin = True
|
||||
if not wallet_id:
|
||||
if user.wallets and not wallet_name: # type: ignore
|
||||
|
@ -163,7 +159,7 @@ async def wallet(
|
|||
"request": request,
|
||||
"user": user.dict(), # type: ignore
|
||||
"wallet": userwallet.dict(),
|
||||
"service_fee": service_fee,
|
||||
"service_fee": settings.lnbits_service_fee,
|
||||
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||
},
|
||||
)
|
||||
|
@ -185,7 +181,7 @@ async def lnurl_full_withdraw(request: Request):
|
|||
"k1": "0",
|
||||
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
||||
"maxWithdrawable": wallet.withdrawable_balance,
|
||||
"defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
|
||||
"defaultDescription": f"{settings.lnbits_site_title} balance withdraw from {wallet.id[0:5]}",
|
||||
"balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id),
|
||||
}
|
||||
|
||||
|
@ -284,12 +280,12 @@ async def manifest(usr: str):
|
|||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||
|
||||
return {
|
||||
"short_name": LNBITS_SITE_TITLE,
|
||||
"name": LNBITS_SITE_TITLE + " Wallet",
|
||||
"short_name": settings.lnbits_site_title,
|
||||
"name": settings.lnbits_site_title + " Wallet",
|
||||
"icons": [
|
||||
{
|
||||
"src": LNBITS_CUSTOM_LOGO
|
||||
if LNBITS_CUSTOM_LOGO
|
||||
"src": settings.lnbits_custom_logo
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
||||
"type": "image/png",
|
||||
"sizes": "900x900",
|
||||
|
@ -311,3 +307,19 @@ async def manifest(usr: str):
|
|||
for wallet in user.wallets
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@core_html_routes.get("/admin", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_admin)): # type: ignore
|
||||
WALLET = get_wallet_class()
|
||||
_, balance = await WALLET.status()
|
||||
|
||||
return template_renderer().TemplateResponse(
|
||||
"admin/index.html",
|
||||
{
|
||||
"request": request,
|
||||
"user": user.dict(),
|
||||
"settings": settings.dict(),
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
|
|
34
lnbits/db.py
34
lnbits/db.py
|
@ -11,7 +11,7 @@ from sqlalchemy import create_engine
|
|||
from sqlalchemy_aio.base import AsyncConnection
|
||||
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore
|
||||
|
||||
from .settings import LNBITS_DATA_FOLDER, LNBITS_DATABASE_URL
|
||||
from lnbits.settings import settings
|
||||
|
||||
POSTGRES = "POSTGRES"
|
||||
COCKROACH = "COCKROACH"
|
||||
|
@ -29,6 +29,13 @@ class Compat:
|
|||
return f"{seconds}"
|
||||
return "<nothing>"
|
||||
|
||||
def datetime_to_timestamp(self, date: datetime.datetime):
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
return date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
elif self.type == SQLITE:
|
||||
return time.mktime(date.timetuple())
|
||||
return "<nothing>"
|
||||
|
||||
@property
|
||||
def timestamp_now(self) -> str:
|
||||
if self.type in {POSTGRES, COCKROACH}:
|
||||
|
@ -114,8 +121,8 @@ class Database(Compat):
|
|||
def __init__(self, db_name: str):
|
||||
self.name = db_name
|
||||
|
||||
if LNBITS_DATABASE_URL:
|
||||
database_uri = LNBITS_DATABASE_URL
|
||||
if settings.lnbits_database_url:
|
||||
database_uri = settings.lnbits_database_url
|
||||
|
||||
if database_uri.startswith("cockroachdb://"):
|
||||
self.type = COCKROACH
|
||||
|
@ -125,6 +132,8 @@ class Database(Compat):
|
|||
import psycopg2 # type: ignore
|
||||
|
||||
def _parse_timestamp(value, _):
|
||||
if value is None:
|
||||
return None
|
||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||
if not "." in value:
|
||||
f = "%Y-%m-%d %H:%M:%S"
|
||||
|
@ -149,25 +158,20 @@ class Database(Compat):
|
|||
|
||||
psycopg2.extensions.register_type(
|
||||
psycopg2.extensions.new_type(
|
||||
(1184, 1114),
|
||||
"TIMESTAMP2INT",
|
||||
_parse_timestamp
|
||||
# lambda value, curs: time.mktime(
|
||||
# datetime.datetime.strptime(
|
||||
# value, "%Y-%m-%d %H:%M:%S.%f"
|
||||
# ).timetuple()
|
||||
# ),
|
||||
(1184, 1114), "TIMESTAMP2INT", _parse_timestamp
|
||||
)
|
||||
)
|
||||
else:
|
||||
if os.path.isdir(LNBITS_DATA_FOLDER):
|
||||
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
|
||||
if os.path.isdir(settings.lnbits_data_folder):
|
||||
self.path = os.path.join(
|
||||
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
||||
)
|
||||
database_uri = f"sqlite:///{self.path}"
|
||||
self.type = SQLITE
|
||||
else:
|
||||
raise NotADirectoryError(
|
||||
f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created"
|
||||
f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again"
|
||||
f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created"
|
||||
f" - please 'mkdir {settings.lnbits_data_folder}' and try again"
|
||||
)
|
||||
logger.trace(f"database {self.type} added for {self.name}")
|
||||
self.schema = self.name
|
||||
|
|
|
@ -14,11 +14,7 @@ from starlette.requests import Request
|
|||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.core.models import User, Wallet
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import (
|
||||
LNBITS_ADMIN_EXTENSIONS,
|
||||
LNBITS_ADMIN_USERS,
|
||||
LNBITS_ALLOWED_USERS,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class KeyChecker(SecurityBase):
|
||||
|
@ -150,8 +146,12 @@ async def get_key_type(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
if (
|
||||
LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS
|
||||
) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
||||
wallet.wallet.user != settings.super_user
|
||||
and wallet.wallet.user not in settings.lnbits_admin_users
|
||||
) and (
|
||||
settings.lnbits_admin_extensions
|
||||
and pathname in settings.lnbits_admin_extensions
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="User not authorized for this extension.",
|
||||
|
@ -227,17 +227,45 @@ async def require_invoice_key(
|
|||
|
||||
async def check_user_exists(usr: UUID4) -> User:
|
||||
g().user = await get_user(usr.hex)
|
||||
|
||||
if not g().user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
)
|
||||
|
||||
if LNBITS_ALLOWED_USERS and g().user.id not in LNBITS_ALLOWED_USERS:
|
||||
if (
|
||||
len(settings.lnbits_allowed_users) > 0
|
||||
and g().user.id not in settings.lnbits_allowed_users
|
||||
and g().user.id != settings.super_user
|
||||
and g().user.id not in settings.lnbits_admin_users
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||
)
|
||||
|
||||
if LNBITS_ADMIN_USERS and g().user.id in LNBITS_ADMIN_USERS:
|
||||
g().user.admin = True
|
||||
|
||||
return g().user
|
||||
|
||||
|
||||
async def check_admin(usr: UUID4) -> User:
|
||||
user = await check_user_exists(usr)
|
||||
if user.id != settings.super_user and not user.id in settings.lnbits_admin_users:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="User not authorized. No admin privileges.",
|
||||
)
|
||||
user.admin = True
|
||||
user.super_user = False
|
||||
if user.id == settings.super_user:
|
||||
user.super_user = True
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def check_super_user(usr: UUID4) -> User:
|
||||
user = await check_admin(usr)
|
||||
if user.id != settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED,
|
||||
detail="User not authorized. No super user privileges.",
|
||||
)
|
||||
return user
|
||||
|
|
|
@ -6,41 +6,54 @@ This extension allows you to link your Bolt Card (or other compatible NXP NTAG d
|
|||
|
||||
**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!***
|
||||
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNBits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [Boltcard NFC Card Creator](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
|
||||
## About the keys
|
||||
|
||||
Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set:
|
||||
Up to five 16-byte keys can be stored on the card, numbered from 00 to 04. In the empty state they all should be set to zeros (00000000000000000000000000000000). For this extension only two keys need to be set, but for the security reasons all five keys should be changed from default (empty) state. The keys directly needed by this extension are:
|
||||
|
||||
One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1.
|
||||
- One for encrypting the card UID and the counter (p parameter), let's called it meta key, key #01 or K1.
|
||||
|
||||
One for calculating CMAC (c parameter), let's called it file key, key #02 or K2.
|
||||
- One for calculating CMAC (c parameter), let's called it file key, key #02 or K2.
|
||||
|
||||
The key #00, K0 (also know as auth key) is skipped to be use as authentification key. Is not needed by this extension, but can be filled in order to write the keys in cooperation with bolt-nfc-android-app.
|
||||
The key #00, K0 (also know as auth key) is used as authentification key. It is not directly needed by this extension, but should be filled in order to write the keys in cooperation with Boltcard NFC Card Creator. In this case also K3 is set to same value as K1 and K4 as K2, so all keys are changed from default values. Keep that in your mind in case you ever need to reset the keys manually.
|
||||
|
||||
***Always backup all keys that you're trying to write on the card. Without them you may not be able to change them in the future!***
|
||||
|
||||
## Setting the card - bolt-nfc-android-app (easy way)
|
||||
So far, regarding the keys, the app can only write a new key set on an empty card (with zero keys). **When you write non zero (and 'non debug') keys, they can't be rewrite with this app.** You have to do it on your computer.
|
||||
|
||||
- Read the card with the app. Note UID so you can fill it in the extension later.
|
||||
- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}`
|
||||
- `{external_id}` should be replaced with the External ID found in the LNBits dialog.
|
||||
## Setting the card - Boltcard NFC Card Creator (easy way)
|
||||
Updated for v0.1.3
|
||||
|
||||
- Add new card in the extension.
|
||||
- Set a max sats per transaction. Any transaction greater than this amount will be rejected.
|
||||
- Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected.
|
||||
- Set a card name. This is just for your reference inside LNBits.
|
||||
- Set a card name. This is just for your reference inside LNbits.
|
||||
- Set the card UID. This is the unique identifier on your NFC card and is 7 bytes.
|
||||
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
|
||||
- Otherwise read it with the Android app (Advanced -> Read NFC) and paste it to the field.
|
||||
- Advanced Options
|
||||
- Card Keys (k0, k1, k2) will be automatically generated if not explicitly set.
|
||||
- Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in debug mode.
|
||||
- GENERATE KEY button fill the keys randomly. If there is "debug" in the card name, a debug set of keys is filled instead.
|
||||
- Set to 16 bytes of 0s (00000000000000000000000000000000) to leave the keys in default (empty) state (this is unsecure).
|
||||
- GENERATE KEY button fill the keys randomly.
|
||||
- Click CREATE CARD button
|
||||
- Click the QR code button next to a card to view its details. You can scan the QR code with the Android app to import the keys.
|
||||
- Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. You can then paste this into the Android app to import the keys.
|
||||
- Tap the NFC card to write the keys to the card.
|
||||
- Click the QR code button next to a card to view its details. Backup the keys now! They'll be comfortable in your password manager.
|
||||
- Now you can scan the QR code with the Android app (Create Bolt Card -> SCAN QR CODE).
|
||||
- Or you can Click the "KEYS / AUTH LINK" button to copy the auth URL to the clipboard. Then paste it into the Android app (Create Bolt Card -> PASTE AUTH URL).
|
||||
- Click WRITE CARD NOW and approach the NFC card to set it up. DO NOT REMOVE THE CARD PREMATURELY!
|
||||
|
||||
## Erasing the card - Boltcard NFC Card Creator
|
||||
Updated for v0.1.3
|
||||
|
||||
Since v0.1.2 of Boltcard NFC Card Creator it is possible not only reset the keys but also disable the SUN function and do the complete erase so the card can be use again as a static tag (or set as a new Bolt Card, ofc).
|
||||
|
||||
- Click the QR code button next to a card to view its details and select WIPE
|
||||
- OR click the red cross icon on the right side to reach the same
|
||||
- In the android app (Advanced -> Reset Keys)
|
||||
- Click SCAN QR CODE to scan the QR
|
||||
- Or click WIPE DATA in LNbits to copy and paste in to the app (PASTE KEY JSON)
|
||||
- Click RESET CARD NOW and approach the NFC card to erase it. DO NOT REMOVE THE CARD PREMATURELY!
|
||||
- Now if there is all success the card can be safely delete from LNbits (but keep the keys backuped anyway; batter safe than brick).
|
||||
|
||||
## Setting the card - computer (hard way)
|
||||
|
||||
|
@ -48,7 +61,7 @@ Follow the guide.
|
|||
|
||||
The URI should be `lnurlw://YOUR-DOMAIN.COM/boltcards/api/v1/scan/{YOUR_card_external_id}?p=00000000000000000000000000000000&c=0000000000000000`
|
||||
|
||||
Then fill up the card parameters in the extension. Card Auth key (K0) can be omitted. Initical counter can be 0.
|
||||
Then fill up the card parameters in the extension. Card Auth key (K0) can be filled in the extension just for the record. Initical counter can be 0.
|
||||
|
||||
## Setting the card - android NXP app (hard way)
|
||||
- If you don't know the card ID, use NXP TagInfo app to find it out.
|
||||
|
@ -70,4 +83,4 @@ Then fill up the card parameters in the extension. Card Auth key (K0) can be omi
|
|||
- Save & Write
|
||||
- Scan with compatible Wallet
|
||||
|
||||
This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secure. Card Auth key (K0) can be omitted anyway. Initical counter can be 0.
|
||||
This app afaik cannot change the keys. If you cannot change them any other way, leave them empty in the extension dialog and remember you're not secured. Card Auth key (K0) can be omitted anyway. Initical counter can be 0.
|
||||
|
|
|
@ -171,6 +171,9 @@ async def get_hit(hit_id: str) -> Optional[Hit]:
|
|||
|
||||
|
||||
async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]:
|
||||
if len(cards_ids) == 0:
|
||||
return []
|
||||
|
||||
q = ",".join(["?"] * len(cards_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,)
|
||||
|
@ -265,6 +268,9 @@ async def get_refund(refund_id: str) -> Optional[Refund]:
|
|||
|
||||
|
||||
async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]:
|
||||
if len(hits_ids) == 0:
|
||||
return []
|
||||
|
||||
q = ",".join(["?"] * len(hits_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,)
|
||||
|
|
|
@ -1,21 +1,13 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from embit import bech32, compact
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends, Query
|
||||
from lnurl import Lnurl, LnurlWithdrawResponse
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
@ -33,7 +25,6 @@ from .crud import (
|
|||
get_hit,
|
||||
get_hits_today,
|
||||
spend_hit,
|
||||
update_card,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
)
|
||||
|
@ -108,15 +99,27 @@ async def lnurl_callback(
|
|||
pr: str = Query(None),
|
||||
k1: str = Query(None),
|
||||
):
|
||||
if not k1:
|
||||
return {"status": "ERROR", "reason": "Missing K1 token"}
|
||||
|
||||
hit = await get_hit(k1)
|
||||
card = await get_card(hit.card_id)
|
||||
|
||||
if not hit:
|
||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
||||
if hit.id != k1:
|
||||
return {"status": "ERROR", "reason": "Bad K1"}
|
||||
return {
|
||||
"status": "ERROR",
|
||||
"reason": "Record not found for this charge (bad k1)",
|
||||
}
|
||||
if hit.spent:
|
||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||
invoice = bolt11.decode(pr)
|
||||
return {"status": "ERROR", "reason": "Payment already claimed"}
|
||||
if not pr:
|
||||
return {"status": "ERROR", "reason": "Missing payment request"}
|
||||
|
||||
try:
|
||||
invoice = bolt11.decode(pr)
|
||||
except:
|
||||
return {"status": "ERROR", "reason": "Failed to decode payment request"}
|
||||
|
||||
card = await get_card(hit.card_id)
|
||||
hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000))
|
||||
try:
|
||||
await pay_invoice(
|
||||
|
@ -126,8 +129,8 @@ async def lnurl_callback(
|
|||
extra={"tag": "boltcard", "tag": hit.id},
|
||||
)
|
||||
return {"status": "OK"}
|
||||
except:
|
||||
return {"status": "ERROR", "reason": f"Payment failed"}
|
||||
except Exception as exc:
|
||||
return {"status": "ERROR", "reason": f"Payment failed - {exc}"}
|
||||
|
||||
|
||||
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
||||
|
|
|
@ -149,6 +149,7 @@ new Vue({
|
|||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
wipe: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
|
@ -259,9 +260,10 @@ new Vue({
|
|||
})
|
||||
})
|
||||
},
|
||||
openQrCodeDialog(cardId) {
|
||||
openQrCodeDialog(cardId, wipe) {
|
||||
var card = _.findWhere(this.cards, {id: cardId})
|
||||
this.qrCodeDialog.data = {
|
||||
id: card.id,
|
||||
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp,
|
||||
name: card.card_name,
|
||||
uid: card.uid,
|
||||
|
@ -272,6 +274,17 @@ new Vue({
|
|||
k3: card.k1,
|
||||
k4: card.k2
|
||||
}
|
||||
this.qrCodeDialog.data_wipe = JSON.stringify({
|
||||
action: 'wipe',
|
||||
k0: card.k0,
|
||||
k1: card.k1,
|
||||
k2: card.k2,
|
||||
k3: card.k1,
|
||||
k4: card.k2,
|
||||
uid: card.uid,
|
||||
version: 1
|
||||
})
|
||||
this.qrCodeDialog.wipe = wipe
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
addCardOpen: function () {
|
||||
|
@ -397,8 +410,16 @@ new Vue({
|
|||
let self = this
|
||||
let cards = _.findWhere(this.cards, {id: cardId})
|
||||
|
||||
Quasar.utils.exportFile(
|
||||
cards.card_name + '.json',
|
||||
this.qrCodeDialog.data_wipe,
|
||||
'application/json'
|
||||
)
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this card')
|
||||
.confirmDialog(
|
||||
"Are you sure you want to delete this card? Without access to the card keys you won't be able to reset them in the future!"
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
|
|
|
@ -48,6 +48,7 @@
|
|||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
|
@ -58,7 +59,7 @@
|
|||
dense
|
||||
icon="qr_code"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
@click="openQrCodeDialog(props.row.id, false)"
|
||||
>
|
||||
<q-tooltip>Card key credentials</q-tooltip>
|
||||
</q-btn>
|
||||
|
@ -99,7 +100,7 @@
|
|||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteCard(props.row.id)"
|
||||
@click="openQrCodeDialog(props.row.id, true)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
|
@ -215,6 +216,7 @@
|
|||
emit-value
|
||||
v-model="cardDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
:disable="cardDialog.data.id != null"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
|
@ -283,7 +285,7 @@
|
|||
v-model="toggleAdvanced"
|
||||
label="Show advanced options"
|
||||
></q-toggle>
|
||||
<div v-show="toggleAdvanced">
|
||||
<div v-show="toggleAdvanced" class="q-gutter-y-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
|
@ -358,44 +360,105 @@
|
|||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||
{% raw %}
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.link"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p style="word-break: break-all" class="text-center">
|
||||
(Keys for
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||
target="_blank"
|
||||
>bolt-nfc-android-app</a
|
||||
>)
|
||||
</p>
|
||||
<div class="col q-mt-lg text-center">
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-xl q-mb-md"
|
||||
v-show="!qrCodeDialog.wipe"
|
||||
>
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.link"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p class="text-center" v-show="!qrCodeDialog.wipe">
|
||||
(QR for <strong>create</strong> the card in
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||
target="_blank"
|
||||
style="color: inherit"
|
||||
>Boltcard NFC Card Creator</a
|
||||
>)
|
||||
</p>
|
||||
<q-responsive
|
||||
:ratio="1"
|
||||
class="q-mx-xl q-mb-md"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
>
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data_wipe"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<p class="text-center" v-show="qrCodeDialog.wipe">
|
||||
(QR for <strong>wipe</strong> the card in
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||
target="_blank"
|
||||
style="color: inherit"
|
||||
>Boltcard NFC Card Creator</a
|
||||
>)
|
||||
</p>
|
||||
</div>
|
||||
<div class="col q-mt-md q-mb-md text-center">
|
||||
<q-btn-toggle
|
||||
v-model="qrCodeDialog.wipe"
|
||||
rounded
|
||||
unelevated
|
||||
toggle-color="primary"
|
||||
color="white"
|
||||
text-color="primary"
|
||||
:options="[
|
||||
{label: 'Create', value: false},
|
||||
{label: 'Wipe', value: true}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<p style="word-break: break-all">
|
||||
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br />
|
||||
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br />
|
||||
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br />
|
||||
<strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br />
|
||||
<strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br />
|
||||
<strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<br />
|
||||
<br />
|
||||
Always backup all keys that you're trying to write on the card. Without
|
||||
them you may not be able to change them in the future!<br />
|
||||
<strong>Lock key (K0):</strong> {{ qrCodeDialog.data.k0 }}<br />
|
||||
<strong>Meta key (K1 & K3):</strong> {{ qrCodeDialog.data.k1 }}<br />
|
||||
<strong>File key (K2 & K4):</strong> {{ qrCodeDialog.data.k2 }}<br />
|
||||
</p>
|
||||
<p>
|
||||
Always backup all keys that you're trying to write on the card. Without
|
||||
them you may not be able to change them in the future!
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.link)"
|
||||
label="Keys/Auth link"
|
||||
label="Create link"
|
||||
v-show="!qrCodeDialog.wipe"
|
||||
>
|
||||
<q-tooltip>Click to copy, then paste to NFC Card Creator</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data_wipe)"
|
||||
label="Wipe data"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
>
|
||||
<q-tooltip>Click to copy, then paste to NFC Card Creator</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
outline
|
||||
color="red"
|
||||
@click="deleteCard(qrCodeDialog.data.id)"
|
||||
label="Delete card"
|
||||
v-show="qrCodeDialog.wipe"
|
||||
v-close-popup
|
||||
>
|
||||
<q-tooltip>Backup the keys, or wipe the card first!</q-tooltip>
|
||||
</q-btn>
|
||||
<q-tooltip>Click to copy, then add to NFC card</q-tooltip>
|
||||
|
||||
{% endraw %}
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
|
|
|
@ -12,7 +12,6 @@ from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
|||
from . import boltcards_ext
|
||||
from .crud import (
|
||||
create_card,
|
||||
create_hit,
|
||||
delete_card,
|
||||
enable_disable_card,
|
||||
get_card,
|
||||
|
@ -22,11 +21,9 @@ from .crud import (
|
|||
get_hits,
|
||||
get_refunds,
|
||||
update_card,
|
||||
update_card_counter,
|
||||
update_card_otp,
|
||||
)
|
||||
from .models import CreateCardData
|
||||
from .nxp424 import decryptSUN, getSunMAC
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/cards")
|
||||
|
|
|
@ -12,7 +12,7 @@ from loguru import logger
|
|||
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import BOLTZ_NETWORK, BOLTZ_URL
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .crud import update_swap_status
|
||||
from .mempool import (
|
||||
|
@ -33,9 +33,7 @@ from .models import (
|
|||
)
|
||||
from .utils import check_balance, get_timestamp, req_wrap
|
||||
|
||||
net = NETWORKS[BOLTZ_NETWORK]
|
||||
logger.trace(f"BOLTZ_URL: {BOLTZ_URL}")
|
||||
logger.trace(f"Bitcoin Network: {net['name']}")
|
||||
net = NETWORKS[settings.boltz_network]
|
||||
|
||||
|
||||
async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
|
||||
|
@ -62,7 +60,7 @@ async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
|
|||
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/createswap",
|
||||
f"{settings.boltz_url}/createswap",
|
||||
json={
|
||||
"type": "submarine",
|
||||
"pairId": "BTC/BTC",
|
||||
|
@ -129,7 +127,7 @@ async def create_reverse_swap(
|
|||
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/createswap",
|
||||
f"{settings.boltz_url}/createswap",
|
||||
json={
|
||||
"type": "reversesubmarine",
|
||||
"pairId": "BTC/BTC",
|
||||
|
@ -409,7 +407,7 @@ def check_boltz_limits(amount):
|
|||
def get_boltz_pairs():
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_URL}/getpairs",
|
||||
f"{settings.boltz_url}/getpairs",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
return res.json()
|
||||
|
@ -418,7 +416,7 @@ def get_boltz_pairs():
|
|||
def get_boltz_status(boltzid):
|
||||
res = req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_URL}/swapstatus",
|
||||
f"{settings.boltz_url}/swapstatus",
|
||||
json={"id": boltzid},
|
||||
)
|
||||
return res.json()
|
||||
|
|
|
@ -7,14 +7,11 @@ import websockets
|
|||
from embit.transaction import Transaction
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL, BOLTZ_MEMPOOL_SPACE_URL_WS
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .utils import req_wrap
|
||||
|
||||
logger.trace(f"BOLTZ_MEMPOOL_SPACE_URL: {BOLTZ_MEMPOOL_SPACE_URL}")
|
||||
logger.trace(f"BOLTZ_MEMPOOL_SPACE_URL_WS: {BOLTZ_MEMPOOL_SPACE_URL_WS}")
|
||||
|
||||
websocket_url = f"{BOLTZ_MEMPOOL_SPACE_URL_WS}/api/v1/ws"
|
||||
websocket_url = f"{settings.boltz_mempool_space_url_ws}/api/v1/ws"
|
||||
|
||||
|
||||
async def wait_for_websocket_message(send, message_string):
|
||||
|
@ -33,7 +30,7 @@ async def wait_for_websocket_message(send, message_string):
|
|||
def get_mempool_tx(address):
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/address/{address}/txs",
|
||||
f"{settings.boltz_mempool_space_url}/api/address/{address}/txs",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
txs = res.json()
|
||||
|
@ -70,7 +67,7 @@ def get_fee_estimation() -> int:
|
|||
def get_mempool_fees() -> int:
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/v1/fees/recommended",
|
||||
f"{settings.boltz_mempool_space_url}/api/v1/fees/recommended",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
fees = res.json()
|
||||
|
@ -80,7 +77,7 @@ def get_mempool_fees() -> int:
|
|||
def get_mempool_blockheight() -> int:
|
||||
res = req_wrap(
|
||||
"get",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/blocks/tip/height",
|
||||
f"{settings.boltz_mempool_space_url}/api/blocks/tip/height",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
return int(res.text)
|
||||
|
@ -91,7 +88,7 @@ async def send_onchain_tx(tx: Transaction):
|
|||
logger.debug(f"Boltz - mempool sending onchain tx...")
|
||||
req_wrap(
|
||||
"post",
|
||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/tx",
|
||||
f"{settings.boltz_mempool_space_url}/api/tx",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
content=raw,
|
||||
)
|
||||
|
|
|
@ -14,7 +14,7 @@ from starlette.requests import Request
|
|||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.settings import BOLTZ_MEMPOOL_SPACE_URL
|
||||
from lnbits.settings import settings
|
||||
|
||||
from . import boltz_ext
|
||||
from .boltz import (
|
||||
|
@ -55,7 +55,7 @@ from .utils import check_balance
|
|||
response_model=str,
|
||||
)
|
||||
async def api_mempool_url():
|
||||
return BOLTZ_MEMPOOL_SPACE_URL
|
||||
return settings.boltz_mempool_space_url
|
||||
|
||||
|
||||
# NORMAL SWAP
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "public.html" %} {% block toolbar_title %} {% raw %} {{name}} Cashu
|
||||
{% endraw %} {% endblock %} {% block footer %}{% endblock %} {% block
|
||||
{% extends "public.html" %} {% block toolbar_title %} {% raw %} Cashu {% endraw
|
||||
%} - {{mint_name}} {% endblock %} {% block footer %}{% endblock %} {% block
|
||||
page_container %}
|
||||
<q-page-container>
|
||||
<q-page>
|
||||
|
@ -752,7 +752,13 @@ page_container %}
|
|||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="redeem" color="primary">Receive Tokens</q-btn>
|
||||
<q-btn @click="redeem" color="primary">Receive</q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
icon="content_copy"
|
||||
class="q-mx-0"
|
||||
@click="copyText(receiveData.tokensBase64)"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
icon="photo_camera"
|
||||
|
|
|
@ -27,11 +27,17 @@ async def index(
|
|||
|
||||
@cashu_ext.get("/wallet")
|
||||
async def wallet(request: Request, mint_id: str):
|
||||
cashu = await get_cashu(mint_id)
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
return cashu_renderer().TemplateResponse(
|
||||
"cashu/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"web_manifest": f"/cashu/manifest/{mint_id}.webmanifest",
|
||||
"mint_name": cashu.name,
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -41,7 +47,7 @@ async def cashu(request: Request, mintID):
|
|||
cashu = await get_cashu(mintID)
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
return cashu_renderer().TemplateResponse(
|
||||
"cashu/mint.html",
|
||||
|
@ -54,7 +60,7 @@ async def manifest(cashu_id: str):
|
|||
cashu = await get_cashu(cashu_id)
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
@ -221,7 +221,7 @@ async def mint_coins(
|
|||
|
||||
status: PaymentStatus = await check_transaction_status(cashu.wallet, payment_hash)
|
||||
|
||||
if status.paid != True:
|
||||
if LIGHTNING and status.paid != True:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
|
||||
)
|
||||
|
@ -265,37 +265,51 @@ async def melt_coins(
|
|||
detail="Error: Tokens are from another mint.",
|
||||
)
|
||||
|
||||
assert all([ledger._verify_proof(p) for p in proofs]), HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Could not verify proofs.",
|
||||
)
|
||||
# set proofs as pending
|
||||
await ledger._set_proofs_pending(proofs)
|
||||
|
||||
total_provided = sum([p["amount"] for p in proofs])
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
amount = math.ceil(invoice_obj.amount_msat / 1000)
|
||||
try:
|
||||
ledger._verify_proofs(proofs)
|
||||
|
||||
internal_checking_id = await check_internal(invoice_obj.payment_hash)
|
||||
total_provided = sum([p["amount"] for p in proofs])
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
amount = math.ceil(invoice_obj.amount_msat / 1000)
|
||||
|
||||
if not internal_checking_id:
|
||||
fees_msat = fee_reserve(invoice_obj.amount_msat)
|
||||
else:
|
||||
fees_msat = 0
|
||||
assert total_provided >= amount + fees_msat / 1000, Exception(
|
||||
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
|
||||
)
|
||||
internal_checking_id = await check_internal(invoice_obj.payment_hash)
|
||||
|
||||
await pay_invoice(
|
||||
wallet_id=cashu.wallet,
|
||||
payment_request=invoice,
|
||||
description=f"pay cashu invoice",
|
||||
extra={"tag": "cashu", "cahsu_name": cashu.name},
|
||||
)
|
||||
if not internal_checking_id:
|
||||
fees_msat = fee_reserve(invoice_obj.amount_msat)
|
||||
else:
|
||||
fees_msat = 0
|
||||
assert total_provided >= amount + math.ceil(fees_msat / 1000), Exception(
|
||||
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
|
||||
)
|
||||
logger.debug(f"Cashu: Initiating payment of {total_provided} sats")
|
||||
await pay_invoice(
|
||||
wallet_id=cashu.wallet,
|
||||
payment_request=invoice,
|
||||
description=f"Pay cashu invoice",
|
||||
extra={"tag": "cashu", "cashu_name": cashu.name},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Cashu: Wallet {cashu.wallet} checking PaymentStatus of {invoice_obj.payment_hash}"
|
||||
)
|
||||
status: PaymentStatus = await check_transaction_status(
|
||||
cashu.wallet, invoice_obj.payment_hash
|
||||
)
|
||||
if status.paid == True:
|
||||
logger.debug("Cashu: Payment successful, invalidating proofs")
|
||||
await ledger._invalidate_proofs(proofs)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=f"Cashu: {str(e)}",
|
||||
)
|
||||
finally:
|
||||
# delete proofs from pending list
|
||||
await ledger._unset_proofs_pending(proofs)
|
||||
|
||||
status: PaymentStatus = await check_transaction_status(
|
||||
cashu.wallet, invoice_obj.payment_hash
|
||||
)
|
||||
if status.paid == True:
|
||||
await ledger._invalidate_proofs(proofs)
|
||||
return GetMeltResponse(paid=status.paid, preimage=status.preimage)
|
||||
|
||||
|
||||
|
@ -333,7 +347,7 @@ async def check_fees(
|
|||
fees_msat = fee_reserve(invoice_obj.amount_msat)
|
||||
else:
|
||||
fees_msat = 0
|
||||
return CheckFeesResponse(fee=fees_msat / 1000)
|
||||
return CheckFeesResponse(fee=math.ceil(fees_msat / 1000))
|
||||
|
||||
|
||||
@cashu_ext.post("/api/v1/{cashu_id}/split")
|
||||
|
|
|
@ -7,11 +7,11 @@ from starlette.exceptions import HTTPException
|
|||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_copilot
|
||||
from .views import updater
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
|
@ -65,9 +65,11 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
if payment.extra.get("comment"):
|
||||
await updater(copilot.id, data, payment.extra.get("comment"))
|
||||
await websocketUpdater(
|
||||
copilot.id, str(data) + "-" + str(payment.extra.get("comment"))
|
||||
)
|
||||
|
||||
await updater(copilot.id, data, "none")
|
||||
await websocketUpdater(copilot.id, str(data) + "-none")
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
|
|
|
@ -238,7 +238,7 @@
|
|||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/copilot/ws/' +
|
||||
'/api/v1/ws/' +
|
||||
self.copilot.id
|
||||
} else {
|
||||
localUrl =
|
||||
|
@ -246,7 +246,7 @@
|
|||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/copilot/ws/' +
|
||||
'/api/v1/ws/' +
|
||||
self.copilot.id
|
||||
}
|
||||
this.connection = new WebSocket(localUrl)
|
||||
|
|
|
@ -35,48 +35,3 @@ async def panel(request: Request):
|
|||
return copilot_renderer().TemplateResponse(
|
||||
"copilot/panel.html", {"request": request}
|
||||
)
|
||||
|
||||
|
||||
##################WEBSOCKET ROUTES########################
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket, copilot_id: str):
|
||||
await websocket.accept()
|
||||
websocket.id = copilot_id # type: ignore
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def send_personal_message(self, message: str, copilot_id: str):
|
||||
for connection in self.active_connections:
|
||||
if connection.id == copilot_id: # type: ignore
|
||||
await connection.send_text(message)
|
||||
|
||||
async def broadcast(self, message: str):
|
||||
for connection in self.active_connections:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@copilot_ext.websocket("/ws/{copilot_id}", name="copilot.websocket_by_id")
|
||||
async def websocket_endpoint(websocket: WebSocket, copilot_id: str):
|
||||
await manager.connect(websocket, copilot_id)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
async def updater(copilot_id, data, comment):
|
||||
copilot = await get_copilot(copilot_id)
|
||||
if not copilot:
|
||||
return
|
||||
await manager.send_personal_message(f"{data + '-' + comment}", copilot_id)
|
||||
|
|
|
@ -5,6 +5,7 @@ from fastapi.param_functions import Query
|
|||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import copilot_ext
|
||||
|
@ -16,7 +17,6 @@ from .crud import (
|
|||
update_copilot,
|
||||
)
|
||||
from .models import CreateCopilotData
|
||||
from .views import updater
|
||||
|
||||
#######################COPILOT##########################
|
||||
|
||||
|
@ -92,7 +92,7 @@ async def api_copilot_ws_relay(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
|
||||
)
|
||||
try:
|
||||
await updater(copilot_id, data, comment)
|
||||
await websocketUpdater(copilot_id, str(data) + "-" + str(comment))
|
||||
except:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
|
||||
return ""
|
||||
|
|
|
@ -118,7 +118,7 @@
|
|||
dense
|
||||
v-model.trim="formDialog.data.company_name"
|
||||
label="Company Name"
|
||||
placeholder="LNBits Labs"
|
||||
placeholder="LNbits Labs"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
|
|
|
@ -22,8 +22,8 @@
|
|||
![redirect url](https://i.imgur.com/GMzl0lG.png)
|
||||
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
|
||||
![spotify app setting](https://i.imgur.com/vb0x4Tl.png)
|
||||
- back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
|
||||
- choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
|
||||
- back on LNbits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
|
||||
- choose on which device the LNbits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
|
||||
- and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
|
||||
![select playlists](https://i.imgur.com/g4dbtED.png)
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Help DJ's and music producers conduct music livestreams
|
||||
|
||||
LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
|
||||
LNbits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
|
||||
|
||||
When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional).
|
||||
|
||||
|
@ -25,7 +25,7 @@ The revenue will be sent to a wallet created specifically for that producer, wit
|
|||
![adjust percentage](https://i.imgur.com/9weHKAB.jpg)
|
||||
3. For every different producer added, when adding tracks, a wallet is generated for them\
|
||||
![producer wallet](https://i.imgur.com/YFIZ7Tm.jpg)
|
||||
4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
|
||||
4. On the bottom of the LNbits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
|
||||
5. After all tracks and producers are added, you can start "playing" songs\
|
||||
![play tracks](https://i.imgur.com/7ytiBkq.jpg)
|
||||
6. You'll see the current track playing and a green icon indicating active track also\
|
||||
|
|
|
@ -3,4 +3,4 @@
|
|||
|
||||
Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/.
|
||||
|
||||
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club.
|
||||
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNbits joins the same club.
|
||||
|
|
|
@ -12,7 +12,7 @@ from lnbits import bolt11
|
|||
from lnbits.core.crud import delete_expired_invoices, get_payments
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.decorators import WalletTypeInfo
|
||||
from lnbits.settings import LNBITS_SITE_TITLE, WALLET
|
||||
from lnbits.settings import get_wallet_class, settings
|
||||
|
||||
from . import lndhub_ext
|
||||
from .decorators import check_wallet, require_admin_key
|
||||
|
@ -21,7 +21,7 @@ from .utils import decoded_as_lndhub, to_buffer
|
|||
|
||||
@lndhub_ext.get("/ext/getinfo")
|
||||
async def lndhub_getinfo():
|
||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="bad auth")
|
||||
return {"alias": settings.lnbits_site_title}
|
||||
|
||||
|
||||
class AuthData(BaseModel):
|
||||
|
@ -56,7 +56,7 @@ async def lndhub_addinvoice(
|
|||
_, pr = await create_invoice(
|
||||
wallet_id=wallet.wallet.id,
|
||||
amount=int(data.amt),
|
||||
memo=data.memo or LNBITS_SITE_TITLE,
|
||||
memo=data.memo or settings.lnbits_site_title,
|
||||
extra={"tag": "lndhub"},
|
||||
)
|
||||
except:
|
||||
|
@ -165,6 +165,7 @@ async def lndhub_getuserinvoices(
|
|||
limit: int = Query(20, ge=1, le=20),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
WALLET = get_wallet_class()
|
||||
for invoice in await get_payments(
|
||||
wallet_id=wallet.wallet.id,
|
||||
complete=False,
|
||||
|
|
|
@ -8,12 +8,11 @@ from fastapi import HTTPException
|
|||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import pay_invoice
|
||||
from lnbits.core.services import pay_invoice, websocketUpdater
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
|
||||
from .views import updater
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
|
@ -36,9 +35,8 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
|
||||
)
|
||||
return await updater(
|
||||
return await websocketUpdater(
|
||||
lnurldevicepayment.deviceid,
|
||||
lnurldevicepayment.pin,
|
||||
lnurldevicepayment.payload,
|
||||
str(lnurldevicepayment.pin) + "-" + str(lnurldevicepayment.payload),
|
||||
)
|
||||
return
|
||||
|
|
|
@ -157,9 +157,9 @@
|
|||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
@click="copyText(wslocation + '/lnurldevice/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
|
||||
>{% raw %}{{wslocation}}/lnurldevice/ws/{{settingsDialog.data.id}}{%
|
||||
endraw %}<q-tooltip> Click to copy URL </q-tooltip>
|
||||
@click="copyText(wslocation + '/api/v1/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
|
||||
>{% raw %}{{wslocation}}/api/v1/ws/{{settingsDialog.data.id}}{% endraw
|
||||
%}<q-tooltip> Click to copy URL </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
|
@ -657,7 +657,7 @@
|
|||
lnurlValueFetch: function (lnurl, switchId) {
|
||||
this.lnurlValue = lnurl
|
||||
this.websocketConnector(
|
||||
'wss://' + window.location.host + '/lnurldevice/ws/' + switchId
|
||||
'wss://' + window.location.host + '/api/v1/ws/' + switchId
|
||||
)
|
||||
},
|
||||
addSwitch: function () {
|
||||
|
|
|
@ -2,7 +2,7 @@ from http import HTTPStatus
|
|||
from io import BytesIO
|
||||
|
||||
import pyqrcode
|
||||
from fastapi import Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
@ -63,50 +63,3 @@ async def img(request: Request, lnurldevice_id):
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
|
||||
)
|
||||
return lnurldevice.lnurl(request)
|
||||
|
||||
|
||||
##################WEBSOCKET ROUTES########################
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket, lnurldevice_id: str):
|
||||
await websocket.accept()
|
||||
websocket.id = lnurldevice_id
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def send_personal_message(self, message: str, lnurldevice_id: str):
|
||||
for connection in self.active_connections:
|
||||
if connection.id == lnurldevice_id:
|
||||
await connection.send_text(message)
|
||||
|
||||
async def broadcast(self, message: str):
|
||||
for connection in self.active_connections:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@lnurldevice_ext.websocket("/ws/{lnurldevice_id}", name="lnurldevice.lnurldevice_by_id")
|
||||
async def websocket_endpoint(websocket: WebSocket, lnurldevice_id: str):
|
||||
await manager.connect(websocket, lnurldevice_id)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
async def updater(lnurldevice_id, lnurldevice_pin, lnurldevice_amount):
|
||||
lnurldevice = await get_lnurldevice(lnurldevice_id)
|
||||
if not lnurldevice:
|
||||
return
|
||||
return await manager.send_personal_message(
|
||||
f"{lnurldevice_pin}-{lnurldevice_amount}", lnurldevice_id
|
||||
)
|
||||
|
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
import json
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
|
@ -48,14 +49,22 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
if pay_link.webhook_headers:
|
||||
kwargs["headers"] = json.loads(pay_link.webhook_headers)
|
||||
|
||||
r = await client.post(pay_link.webhook_url, **kwargs)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
r: httpx.Response = await client.post(pay_link.webhook_url, **kwargs)
|
||||
await mark_webhook_sent(
|
||||
payment, r.status_code, r.is_success, r.reason_phrase, r.text
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
await mark_webhook_sent(payment, -1, False, "Unexpected Error", str(ex))
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
payment.extra["wh_status"] = status
|
||||
async def mark_webhook_sent(
|
||||
payment: Payment, status: int, is_success: bool, reason_phrase="", text=""
|
||||
) -> None:
|
||||
payment.extra["wh_status"] = status # keep for backwards compability
|
||||
payment.extra["wh_success"] = is_success
|
||||
payment.extra["wh_message"] = reason_phrase
|
||||
payment.extra["wh_response"] = text
|
||||
|
||||
await core_db.execute(
|
||||
"""
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
[![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop')
|
||||
|
||||
LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
|
||||
LNbits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
|
||||
|
||||
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a customer chooses an item, scans the QR code, gets the description and price. After payment, the customer gets a confirmation code that the merchant can validate to be sure the payment was successful.
|
||||
|
||||
|
@ -23,7 +23,7 @@ Customers must use an LNURL pay capable wallet.
|
|||
![add new item](https://i.imgur.com/pkZqRgj.png)
|
||||
3. After creating some products, click on "PRINT QR CODES"\
|
||||
![print qr codes](https://i.imgur.com/2GAiSTe.png)
|
||||
4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\
|
||||
4. You'll see a QR code for each product in your LNbits Offline Shop with a title and price ready for printing\
|
||||
![qr codes sheet](https://i.imgur.com/faEqOcd.png)
|
||||
5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet
|
||||
6. Choose what type of confirmation do you want customers to report to merchant after a successful payment\
|
||||
|
|
|
@ -18,19 +18,16 @@
|
|||
label="Choose an amount *"
|
||||
:hint="'Minimum ' + paywallAmount + ' sat'"
|
||||
>
|
||||
<template v-slot:after>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
icon="check"
|
||||
color="primary"
|
||||
type="submit"
|
||||
@click="createInvoice"
|
||||
:disabled="userAmount < paywallAmount || paymentReq"
|
||||
></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disabled="userAmount < paywallAmount || paymentReq"
|
||||
@click="createInvoice"
|
||||
>Send</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
<div v-if="paymentReq" class="q-mt-lg">
|
||||
<a :href="'lightning:' + paymentReq">
|
||||
|
|
|
@ -23,5 +23,5 @@ Easilly create invoices that support Lightning Network and on-chain BTC payment.
|
|||
![offchain payment](https://i.imgur.com/4191SMV.png)
|
||||
- or pay on chain\
|
||||
![onchain payment](https://i.imgur.com/wzLRR5N.png)
|
||||
5. You can check the state of your charges in LNBits\
|
||||
5. You can check the state of your charges in LNbits\
|
||||
![invoice state](https://i.imgur.com/JnBd22p.png)
|
||||
|
|
|
@ -37,7 +37,11 @@ async def call_webhook(charge: Charges):
|
|||
json=public_charge(charge),
|
||||
timeout=40,
|
||||
)
|
||||
return {"webhook_success": r.is_success, "webhook_message": r.reason_phrase}
|
||||
return {
|
||||
"webhook_success": r.is_success,
|
||||
"webhook_message": r.reason_phrase,
|
||||
"webhook_response": r.text,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to call webhook for charge {charge.id}")
|
||||
logger.warning(e)
|
||||
|
|
|
@ -23,6 +23,7 @@ const mapCharge = (obj, oldObj = {}) => {
|
|||
charge.displayUrl = ['/satspay/', obj.id].join('')
|
||||
charge.expanded = oldObj.expanded || false
|
||||
charge.pendingBalance = oldObj.pendingBalance || 0
|
||||
charge.extra = charge.extra ? JSON.parse(charge.extra) : charge.extra
|
||||
return charge
|
||||
}
|
||||
|
||||
|
|
|
@ -227,7 +227,12 @@
|
|||
>
|
||||
</div>
|
||||
<div class="col-4 q-pr-lg">
|
||||
<q-badge v-if="props.row.webhook_message" color="blue">
|
||||
<q-badge
|
||||
v-if="props.row.webhook_message"
|
||||
@click="showWebhookResponseDialog(props.row.extra.webhook_response)"
|
||||
color="blue"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{props.row.webhook_message }}
|
||||
</q-badge>
|
||||
</div>
|
||||
|
@ -528,6 +533,23 @@
|
|||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showWebhookResponse" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
readonly
|
||||
v-model.trim="webhookResponse"
|
||||
type="textarea"
|
||||
label="Response"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn flat v-close-popup color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<!-- lnbits/static/vendor
|
||||
|
@ -669,7 +691,9 @@
|
|||
data: {
|
||||
custom_css: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
showWebhookResponse: false,
|
||||
webhookResponse: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -757,7 +781,6 @@
|
|||
'/satspay/api/v1/themes',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
console.log(data)
|
||||
this.themeLinks = data.map(c =>
|
||||
mapCSS(
|
||||
c,
|
||||
|
@ -852,14 +875,12 @@
|
|||
},
|
||||
updateformDialog: function (themeId) {
|
||||
const theme = _.findWhere(this.themeLinks, {css_id: themeId})
|
||||
console.log(theme.css_id)
|
||||
this.formDialogThemes.data.css_id = theme.css_id
|
||||
this.formDialogThemes.data.title = theme.title
|
||||
this.formDialogThemes.data.custom_css = theme.custom_css
|
||||
this.formDialogThemes.show = true
|
||||
},
|
||||
createTheme: async function (wallet, data) {
|
||||
console.log(data.css_id)
|
||||
try {
|
||||
if (data.css_id) {
|
||||
const resp = await LNbits.api.request(
|
||||
|
@ -887,7 +908,6 @@
|
|||
custom_css: ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('cun')
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
|
@ -955,6 +975,10 @@
|
|||
}
|
||||
})
|
||||
},
|
||||
showWebhookResponseDialog(webhookResponse) {
|
||||
this.webhookResponse = webhookResponse
|
||||
this.showWebhookResponse = true
|
||||
},
|
||||
exportchargeCSV: function () {
|
||||
LNbits.utils.exportCSV(
|
||||
this.chargesTable.columns,
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Response
|
||||
from fastapi.param_functions import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
@ -12,7 +10,6 @@ from starlette.responses import HTMLResponse
|
|||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.extensions.satspay.helpers import public_charge
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS
|
||||
|
||||
from . import satspay_ext, satspay_renderer
|
||||
from .crud import get_charge, get_theme
|
||||
|
@ -22,16 +19,14 @@ templates = Jinja2Templates(directory="templates")
|
|||
|
||||
@satspay_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
admin = False
|
||||
if LNBITS_ADMIN_USERS and user.id in LNBITS_ADMIN_USERS:
|
||||
admin = True
|
||||
return satspay_renderer().TemplateResponse(
|
||||
"satspay/index.html", {"request": request, "user": user.dict(), "admin": admin}
|
||||
"satspay/index.html",
|
||||
{"request": request, "user": user.dict(), "admin": user.admin},
|
||||
)
|
||||
|
||||
|
||||
@satspay_ext.get("/{charge_id}", response_class=HTMLResponse)
|
||||
async def display(request: Request, charge_id: str):
|
||||
async def display_charge(request: Request, charge_id: str):
|
||||
charge = await get_charge(charge_id)
|
||||
if not charge:
|
||||
raise HTTPException(
|
||||
|
@ -50,7 +45,7 @@ async def display(request: Request, charge_id: str):
|
|||
|
||||
|
||||
@satspay_ext.get("/css/{css_id}")
|
||||
async def display(css_id: str, response: Response):
|
||||
async def display_css(css_id: str):
|
||||
theme = await get_theme(css_id)
|
||||
if theme:
|
||||
return Response(content=theme.custom_css, media_type="text/css")
|
||||
|
|
|
@ -1,20 +1,19 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.decorators import (
|
||||
WalletTypeInfo,
|
||||
check_admin,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
require_invoice_key,
|
||||
)
|
||||
from lnbits.extensions.satspay import satspay_ext
|
||||
from lnbits.settings import LNBITS_ADMIN_EXTENSIONS, LNBITS_ADMIN_USERS
|
||||
|
||||
from .crud import (
|
||||
check_address_balance,
|
||||
|
@ -139,18 +138,14 @@ async def api_charge_balance(charge_id):
|
|||
#############################THEMES##########################
|
||||
|
||||
|
||||
@satspay_ext.post("/api/v1/themes")
|
||||
@satspay_ext.post("/api/v1/themes/{css_id}")
|
||||
@satspay_ext.post("/api/v1/themes", dependencies=[Depends(check_admin)])
|
||||
@satspay_ext.post("/api/v1/themes/{css_id}", dependencies=[Depends(check_admin)])
|
||||
async def api_themes_save(
|
||||
data: SatsPayThemes,
|
||||
wallet: WalletTypeInfo = Depends(require_invoice_key),
|
||||
css_id: str = None,
|
||||
css_id: str = Query(...),
|
||||
):
|
||||
if LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN,
|
||||
detail="Only server admins can create themes.",
|
||||
)
|
||||
|
||||
if css_id:
|
||||
theme = await save_theme(css_id=css_id, data=data)
|
||||
else:
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Have payments split between multiple wallets
|
||||
|
||||
LNBits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever.
|
||||
LNbits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever.
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from typing import List
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Target
|
||||
|
||||
|
@ -20,8 +22,15 @@ async def set_targets(source_wallet: str, targets: List[Target]):
|
|||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO splitpayments.targets
|
||||
(source, wallet, percent, alias)
|
||||
VALUES (?, ?, ?, ?)
|
||||
(id, source, wallet, percent, tag, alias)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(source_wallet, target.wallet, target.percent, target.alias),
|
||||
(
|
||||
urlsafe_short_hash(),
|
||||
source_wallet,
|
||||
target.wallet,
|
||||
target.percent,
|
||||
target.tag,
|
||||
target.alias,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial split payment table.
|
||||
|
@ -52,3 +55,45 @@ async def m002_float_percent(db):
|
|||
)
|
||||
|
||||
await db.execute("DROP TABLE splitpayments.splitpayments_old")
|
||||
|
||||
|
||||
async def m003_add_id_and_tag(db):
|
||||
"""
|
||||
Add float percent and migrates the existing data.
|
||||
"""
|
||||
await db.execute("ALTER TABLE splitpayments.targets RENAME TO splitpayments_old")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE splitpayments.targets (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
percent REAL NOT NULL CHECK (percent >= 0 AND percent <= 100),
|
||||
tag TEXT NOT NULL,
|
||||
alias TEXT,
|
||||
|
||||
UNIQUE (source, wallet)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
for row in [
|
||||
list(row)
|
||||
for row in await db.fetchall("SELECT * FROM splitpayments.splitpayments_old")
|
||||
]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO splitpayments.targets (
|
||||
id,
|
||||
wallet,
|
||||
source,
|
||||
percent,
|
||||
tag,
|
||||
alias
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(urlsafe_short_hash(), row[0], row[1], row[2], "", row[3]),
|
||||
)
|
||||
|
||||
await db.execute("DROP TABLE splitpayments.splitpayments_old")
|
||||
|
|
|
@ -8,13 +8,15 @@ class Target(BaseModel):
|
|||
wallet: str
|
||||
source: str
|
||||
percent: float
|
||||
tag: str
|
||||
alias: Optional[str]
|
||||
|
||||
|
||||
class TargetPutList(BaseModel):
|
||||
wallet: str = Query(...)
|
||||
alias: str = Query("")
|
||||
percent: float = Query(..., ge=0.01, lt=100)
|
||||
percent: float = Query(..., ge=0, lt=100)
|
||||
tag: str
|
||||
|
||||
|
||||
class TargetPut(BaseModel):
|
||||
|
|
|
@ -10,7 +10,11 @@ function hashTargets(targets) {
|
|||
}
|
||||
|
||||
function isTargetComplete(target) {
|
||||
return target.wallet && target.wallet.trim() !== '' && target.percent > 0
|
||||
return (
|
||||
target.wallet &&
|
||||
target.wallet.trim() !== '' &&
|
||||
(target.percent > 0 || target.tag != '')
|
||||
)
|
||||
}
|
||||
|
||||
new Vue({
|
||||
|
@ -20,7 +24,11 @@ new Vue({
|
|||
return {
|
||||
selectedWallet: null,
|
||||
currentHash: '', // a string that must match if the edit data is unchanged
|
||||
targets: []
|
||||
targets: [
|
||||
{
|
||||
method: 'split'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -37,6 +45,14 @@ new Vue({
|
|||
timeout: 500
|
||||
})
|
||||
},
|
||||
clearTarget(index) {
|
||||
this.targets.splice(index, 1)
|
||||
console.log(this.targets)
|
||||
this.$q.notify({
|
||||
message: 'Removed item. You must click to save manually.',
|
||||
timeout: 500
|
||||
})
|
||||
},
|
||||
getTargets() {
|
||||
LNbits.api
|
||||
.request(
|
||||
|
@ -50,17 +66,41 @@ new Vue({
|
|||
.then(response => {
|
||||
this.currentHash = hashTargets(response.data)
|
||||
this.targets = response.data.concat({})
|
||||
for (let i = 0; i < this.targets.length; i++) {
|
||||
if (this.targets[i].tag.length > 0) {
|
||||
this.targets[i].method = 'tag'
|
||||
} else if (this.targets[i].percent.length > 0) {
|
||||
this.targets[i].method = 'split'
|
||||
} else {
|
||||
this.targets[i].method = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
changedWallet(wallet) {
|
||||
this.selectedWallet = wallet
|
||||
this.getTargets()
|
||||
},
|
||||
targetChanged(isPercent, index) {
|
||||
clearChanged(index) {
|
||||
if (this.targets[index].method == 'split') {
|
||||
this.targets[index].tag = null
|
||||
this.targets[index].method = 'split'
|
||||
} else {
|
||||
this.targets[index].percent = null
|
||||
this.targets[index].method = 'tag'
|
||||
}
|
||||
},
|
||||
targetChanged(index) {
|
||||
// fix percent min and max range
|
||||
if (isPercent) {
|
||||
if (this.targets[index].percent) {
|
||||
if (this.targets[index].percent > 100) this.targets[index].percent = 100
|
||||
if (this.targets[index].percent < 0) this.targets[index].percent = 0
|
||||
this.targets[index].tag = ''
|
||||
}
|
||||
|
||||
// not percentage
|
||||
if (!this.targets[index].percent) {
|
||||
this.targets[index].percent = 0
|
||||
}
|
||||
|
||||
// remove empty lines (except last)
|
||||
|
@ -70,6 +110,7 @@ new Vue({
|
|||
if (
|
||||
(!target.wallet || target.wallet.trim() === '') &&
|
||||
(!target.alias || target.alias.trim() === '') &&
|
||||
(!target.tag || target.tag.trim() === '') &&
|
||||
!target.percent
|
||||
) {
|
||||
this.targets.splice(i, 1)
|
||||
|
@ -79,7 +120,7 @@ new Vue({
|
|||
|
||||
// add a line at the end if the last one is filled
|
||||
let last = this.targets[this.targets.length - 1]
|
||||
if (last.wallet && last.wallet.trim() !== '' && last.percent > 0) {
|
||||
if (last.wallet && last.wallet.trim() !== '') {
|
||||
this.targets.push({})
|
||||
}
|
||||
|
||||
|
@ -108,11 +149,17 @@ new Vue({
|
|||
if (t !== index) target.percent -= +(diff * target.percent).toFixed(2)
|
||||
})
|
||||
}
|
||||
|
||||
// overwrite so changes appear
|
||||
this.targets = this.targets
|
||||
},
|
||||
saveTargets() {
|
||||
for (let i = 0; i < this.targets.length; i++) {
|
||||
if (this.targets[i].tag != '') {
|
||||
this.targets[i].percent = 0
|
||||
} else {
|
||||
this.targets[i].tag = ''
|
||||
}
|
||||
}
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
|
@ -121,7 +168,12 @@ new Vue({
|
|||
{
|
||||
targets: this.targets
|
||||
.filter(isTargetComplete)
|
||||
.map(({wallet, percent, alias}) => ({wallet, percent, alias}))
|
||||
.map(({wallet, percent, tag, alias}) => ({
|
||||
wallet,
|
||||
percent,
|
||||
tag,
|
||||
alias
|
||||
}))
|
||||
}
|
||||
)
|
||||
.then(response => {
|
||||
|
|
|
@ -25,7 +25,7 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
return
|
||||
|
||||
targets = await get_targets(payment.wallet_id)
|
||||
|
||||
logger.debug(targets)
|
||||
if not targets:
|
||||
return
|
||||
|
||||
|
@ -35,21 +35,45 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
logger.error("splitpayment failure: total percent adds up to more than 100%")
|
||||
return
|
||||
|
||||
logger.debug(f"performing split payments to {len(targets)} targets")
|
||||
logger.debug(f"checking if tagged for {len(targets)} targets")
|
||||
tagged = False
|
||||
for target in targets:
|
||||
amount = int(payment.amount * target.percent / 100) # msats
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=target.wallet,
|
||||
amount=int(amount / 1000), # sats
|
||||
internal=True,
|
||||
memo=f"split payment: {target.percent}% for {target.alias or target.wallet}",
|
||||
extra={"tag": "splitpayments"},
|
||||
)
|
||||
logger.debug(f"created split invoice: {payment_hash}")
|
||||
if target.tag in payment.extra:
|
||||
tagged = True
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=target.wallet,
|
||||
amount=int(payment.amount / 1000), # sats
|
||||
internal=True,
|
||||
memo=f"Pushed tagged payment to {target.alias}",
|
||||
extra={"tag": "splitpayments"},
|
||||
)
|
||||
logger.debug(f"created split invoice: {payment_hash}")
|
||||
|
||||
checking_id = await pay_invoice(
|
||||
payment_request=payment_request,
|
||||
wallet_id=payment.wallet_id,
|
||||
extra={"tag": "splitpayments"},
|
||||
)
|
||||
logger.debug(f"paid split invoice: {checking_id}")
|
||||
checking_id = await pay_invoice(
|
||||
payment_request=payment_request,
|
||||
wallet_id=payment.wallet_id,
|
||||
extra={"tag": "splitpayments"},
|
||||
)
|
||||
logger.debug(f"paid split invoice: {checking_id}")
|
||||
|
||||
logger.debug(f"performing split to {len(targets)} targets")
|
||||
|
||||
if tagged == False:
|
||||
for target in targets:
|
||||
if target.percent > 0:
|
||||
amount = int(payment.amount * target.percent / 100) # msats
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=target.wallet,
|
||||
amount=int(amount / 1000), # sats
|
||||
internal=True,
|
||||
memo=f"split payment: {target.percent}% for {target.alias or target.wallet}",
|
||||
extra={"tag": "splitpayments"},
|
||||
)
|
||||
logger.debug(f"created split invoice: {payment_hash}")
|
||||
|
||||
checking_id = await pay_invoice(
|
||||
payment_request=payment_request,
|
||||
wallet_id=payment.wallet_id,
|
||||
extra={"tag": "splitpayments"},
|
||||
)
|
||||
logger.debug(f"paid split invoice: {checking_id}")
|
||||
|
|
|
@ -31,39 +31,80 @@
|
|||
style="flex-wrap: nowrap"
|
||||
v-for="(target, t) in targets"
|
||||
>
|
||||
<q-select
|
||||
dense
|
||||
:options="g.user.wallets.filter(w => w.id !== selectedWallet.id).map(o => ({name: o.name, value: o.id}))"
|
||||
v-model="target.wallet"
|
||||
label="Wallet"
|
||||
:hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined"
|
||||
@input="targetChanged(false)"
|
||||
option-label="name"
|
||||
style="width: 1000px"
|
||||
new-value-mode="add-unique"
|
||||
use-input
|
||||
input-debounce="0"
|
||||
emit-value
|
||||
></q-select>
|
||||
<q-input
|
||||
dense
|
||||
outlined
|
||||
v-model="target.alias"
|
||||
label="Alias"
|
||||
:hint="t === targets.length - 1 ? 'A name to identify this target wallet locally.' : undefined"
|
||||
@input="targetChanged(false)"
|
||||
style="width: 150px"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
dense
|
||||
v-model="target.wallet"
|
||||
label="Wallet"
|
||||
:hint="t === targets.length - 1 ? 'A wallet ID or invoice key.' : undefined"
|
||||
option-label="name"
|
||||
style="width: 300px"
|
||||
new-value-mode="add-unique"
|
||||
use-input
|
||||
input-debounce="0"
|
||||
emit-value
|
||||
></q-input>
|
||||
|
||||
<q-toggle
|
||||
:false-value="'split'"
|
||||
:true-value="'tag'"
|
||||
color="primary"
|
||||
label=""
|
||||
value="True"
|
||||
style="width: 180px"
|
||||
v-model="target.method"
|
||||
:label="`${target.method}` === 'tag' ? 'Send funds by tag' : `${target.method}` === 'split' ? 'Split funds by %' : 'Split/tag?'"
|
||||
@input="clearChanged(t)"
|
||||
></q-toggle>
|
||||
|
||||
<q-input
|
||||
v-if="target.method == 'tag'"
|
||||
style="width: 150px"
|
||||
dense
|
||||
outlined
|
||||
v-model="target.tag"
|
||||
label="Tag name"
|
||||
suffix="#"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
v-else-if="target.method == 'split' || target.percent >= 0"
|
||||
style="width: 150px"
|
||||
dense
|
||||
outlined
|
||||
v-model.number="target.percent"
|
||||
label="Split Share"
|
||||
:hint="t === targets.length - 1 ? 'How much of the incoming payments will go to the target wallet.' : undefined"
|
||||
label="split"
|
||||
suffix="%"
|
||||
@input="targetChanged(true, t)"
|
||||
></q-input>
|
||||
</div>
|
||||
|
||||
<q-btn
|
||||
v-if="t == targets.length - 1 && (target.method == 'tag' || target.method == 'split')"
|
||||
round
|
||||
size="sm"
|
||||
icon="add"
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="targetChanged(t)"
|
||||
>
|
||||
<q-tooltip>Add more</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="t < targets.length - 1"
|
||||
@click="clearTarget(t)"
|
||||
round
|
||||
color="red"
|
||||
size="5px"
|
||||
icon="close"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="row justify-evenly q-pa-lg">
|
||||
<div>
|
||||
<q-btn unelevated outline color="secondary" @click="clearTargets">
|
||||
|
@ -76,7 +117,7 @@
|
|||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
:disabled="!isDirty"
|
||||
:disabled="targets.length < 2"
|
||||
>
|
||||
Save Targets
|
||||
</q-btn>
|
||||
|
|
|
@ -50,16 +50,15 @@ async def api_targets_set(
|
|||
Target(
|
||||
wallet=wallet.id,
|
||||
source=wal.wallet.id,
|
||||
tag=entry.tag,
|
||||
percent=entry.percent,
|
||||
alias=entry.alias,
|
||||
)
|
||||
)
|
||||
|
||||
percent_sum = sum([target.percent for target in targets])
|
||||
if percent_sum > 100:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Splitting over 100%."
|
||||
)
|
||||
|
||||
percent_sum = sum([target.percent for target in targets])
|
||||
if percent_sum > 100:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail="Splitting over 100%."
|
||||
)
|
||||
await set_targets(wal.wallet.id, targets)
|
||||
return ""
|
||||
|
|
|
@ -19,7 +19,7 @@ So the goal of the extension is to allow the owner of a domain to sell subdomain
|
|||
4. Get Cloudflare API TOKEN
|
||||
<img src="https://i.imgur.com/BZbktTy.png">
|
||||
<img src="https://i.imgur.com/YDZpW7D.png">
|
||||
5. Open the LNBits subdomains extension and register your domain
|
||||
5. Open the LNbits subdomains extension and register your domain
|
||||
6. Click on the button in the table to open the public form that was generated for your domain
|
||||
|
||||
- Extension also supports webhooks so you can get notified when someone buys a new subdomain\
|
||||
|
|
|
@ -3,7 +3,7 @@ import asyncio
|
|||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.core.services import create_invoice, pay_invoice, websocketUpdater
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
|
@ -26,6 +26,16 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
tpos = await get_tpos(payment.extra.get("tposId"))
|
||||
tipAmount = payment.extra.get("tipAmount")
|
||||
|
||||
strippedPayment = {
|
||||
"amount": payment.amount,
|
||||
"fee": payment.fee,
|
||||
"checking_id": payment.checking_id,
|
||||
"payment_hash": payment.payment_hash,
|
||||
"bolt11": payment.bolt11,
|
||||
}
|
||||
|
||||
await websocketUpdater(payment.extra.get("tposId"), str(strippedPayment))
|
||||
|
||||
if tipAmount is None:
|
||||
# no tip amount
|
||||
return
|
||||
|
|
|
@ -8,7 +8,7 @@ from starlette.responses import HTMLResponse
|
|||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.settings import LNBITS_CUSTOM_LOGO, LNBITS_SITE_TITLE
|
||||
from lnbits.settings import settings
|
||||
|
||||
from . import tpos_ext, tpos_renderer
|
||||
from .crud import get_tpos
|
||||
|
@ -50,12 +50,12 @@ async def manifest(tpos_id: str):
|
|||
)
|
||||
|
||||
return {
|
||||
"short_name": LNBITS_SITE_TITLE,
|
||||
"name": tpos.name + " - " + LNBITS_SITE_TITLE,
|
||||
"short_name": settings.lnbits_site_title,
|
||||
"name": tpos.name + " - " + settings.lnbits_site_title,
|
||||
"icons": [
|
||||
{
|
||||
"src": LNBITS_CUSTOM_LOGO
|
||||
if LNBITS_CUSTOM_LOGO
|
||||
"src": settings.lnbits_custom_logo
|
||||
if settings.lnbits_custom_logo
|
||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
||||
"type": "image/png",
|
||||
"sizes": "900x900",
|
||||
|
@ -69,9 +69,9 @@ async def manifest(tpos_id: str):
|
|||
"theme_color": "#1F2234",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": tpos.name + " - " + LNBITS_SITE_TITLE,
|
||||
"name": tpos.name + " - " + settings.lnbits_site_title,
|
||||
"short_name": tpos.name,
|
||||
"description": tpos.name + " - " + LNBITS_SITE_TITLE,
|
||||
"description": tpos.name + " - " + settings.lnbits_site_title,
|
||||
"url": "/tpos/" + tpos_id,
|
||||
}
|
||||
],
|
||||
|
|
|
@ -12,6 +12,7 @@ from lnbits.core.models import Payment
|
|||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.settings import settings
|
||||
|
||||
from . import tpos_ext
|
||||
from .crud import create_tpos, delete_tpos, get_tpos, get_tposs
|
||||
|
@ -134,7 +135,8 @@ async def api_tpos_pay_invoice(
|
|||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(lnurl, follow_redirects=True)
|
||||
headers = {"user-agent": f"lnbits/tpos commit {settings.lnbits_commit[:7]}"}
|
||||
r = await client.get(lnurl, follow_redirects=True, headers=headers)
|
||||
if r.is_error:
|
||||
lnurl_response = {"success": False, "detail": "Error loading"}
|
||||
else:
|
||||
|
@ -145,6 +147,7 @@ async def api_tpos_pay_invoice(
|
|||
r2 = await client.get(
|
||||
resp["callback"],
|
||||
follow_redirects=True,
|
||||
headers=headers,
|
||||
params={
|
||||
"k1": resp["k1"],
|
||||
"pr": payment_request,
|
||||
|
|
|
@ -10,12 +10,10 @@ from lnbits.core.crud import (
|
|||
from lnbits.core.models import Payment
|
||||
|
||||
from . import db
|
||||
from .models import CreateUserData, Users, Wallets
|
||||
|
||||
### Users
|
||||
from .models import CreateUserData, User, Wallet
|
||||
|
||||
|
||||
async def create_usermanager_user(data: CreateUserData) -> Users:
|
||||
async def create_usermanager_user(data: CreateUserData) -> User:
|
||||
account = await create_account()
|
||||
user = await get_user(account.id)
|
||||
assert user, "Newly created user couldn't be retrieved"
|
||||
|
@ -50,17 +48,17 @@ async def create_usermanager_user(data: CreateUserData) -> Users:
|
|||
return user_created
|
||||
|
||||
|
||||
async def get_usermanager_user(user_id: str) -> Optional[Users]:
|
||||
async def get_usermanager_user(user_id: str) -> Optional[User]:
|
||||
row = await db.fetchone("SELECT * FROM usermanager.users WHERE id = ?", (user_id,))
|
||||
return Users(**row) if row else None
|
||||
return User(**row) if row else None
|
||||
|
||||
|
||||
async def get_usermanager_users(user_id: str) -> List[Users]:
|
||||
async def get_usermanager_users(user_id: str) -> List[User]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM usermanager.users WHERE admin = ?", (user_id,)
|
||||
)
|
||||
|
||||
return [Users(**row) for row in rows]
|
||||
return [User(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_usermanager_user(user_id: str, delete_core: bool = True) -> None:
|
||||
|
@ -73,12 +71,9 @@ async def delete_usermanager_user(user_id: str, delete_core: bool = True) -> Non
|
|||
await db.execute("""DELETE FROM usermanager.wallets WHERE "user" = ?""", (user_id,))
|
||||
|
||||
|
||||
### Wallets
|
||||
|
||||
|
||||
async def create_usermanager_wallet(
|
||||
user_id: str, wallet_name: str, admin_id: str
|
||||
) -> Wallets:
|
||||
) -> Wallet:
|
||||
wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name)
|
||||
await db.execute(
|
||||
"""
|
||||
|
@ -92,28 +87,28 @@ async def create_usermanager_wallet(
|
|||
return wallet_created
|
||||
|
||||
|
||||
async def get_usermanager_wallet(wallet_id: str) -> Optional[Wallets]:
|
||||
async def get_usermanager_wallet(wallet_id: str) -> Optional[Wallet]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM usermanager.wallets WHERE id = ?", (wallet_id,)
|
||||
)
|
||||
return Wallets(**row) if row else None
|
||||
return Wallet(**row) if row else None
|
||||
|
||||
|
||||
async def get_usermanager_wallets(admin_id: str) -> Optional[Wallets]:
|
||||
async def get_usermanager_wallets(admin_id: str) -> List[Wallet]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM usermanager.wallets WHERE admin = ?", (admin_id,)
|
||||
)
|
||||
return [Wallets(**row) for row in rows]
|
||||
return [Wallet(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_usermanager_users_wallets(user_id: str) -> Optional[Wallets]:
|
||||
async def get_usermanager_users_wallets(user_id: str) -> List[Wallet]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM usermanager.wallets WHERE "user" = ?""", (user_id,)
|
||||
)
|
||||
return [Wallets(**row) for row in rows]
|
||||
return [Wallet(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_usermanager_wallet_transactions(wallet_id: str) -> Optional[Payment]:
|
||||
async def get_usermanager_wallet_transactions(wallet_id: str) -> List[Payment]:
|
||||
return await get_payments(
|
||||
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
|
||||
)
|
||||
|
|
|
@ -19,7 +19,7 @@ class CreateUserWallet(BaseModel):
|
|||
admin_id: str = Query(...)
|
||||
|
||||
|
||||
class Users(BaseModel):
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
admin: str
|
||||
|
@ -27,7 +27,7 @@ class Users(BaseModel):
|
|||
password: Optional[str] = None
|
||||
|
||||
|
||||
class Wallets(BaseModel):
|
||||
class Wallet(BaseModel):
|
||||
id: str
|
||||
admin: str
|
||||
name: str
|
||||
|
@ -36,5 +36,5 @@ class Wallets(BaseModel):
|
|||
inkey: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Wallets":
|
||||
def from_row(cls, row: Row) -> "Wallet":
|
||||
return cls(**dict(row))
|
||||
|
|
|
@ -9,7 +9,9 @@ from . import usermanager_ext, usermanager_renderer
|
|||
|
||||
|
||||
@usermanager_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists) # type: ignore
|
||||
):
|
||||
return usermanager_renderer().TemplateResponse(
|
||||
"usermanager/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
|
|
@ -23,25 +23,31 @@ from .crud import (
|
|||
)
|
||||
from .models import CreateUserData, CreateUserWallet
|
||||
|
||||
# Users
|
||||
|
||||
|
||||
@usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
|
||||
async def api_usermanager_users(wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
async def api_usermanager_users(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
):
|
||||
user_id = wallet.wallet.user
|
||||
return [user.dict() for user in await get_usermanager_users(user_id)]
|
||||
|
||||
|
||||
@usermanager_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK)
|
||||
async def api_usermanager_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
@usermanager_ext.get(
|
||||
"/api/v1/users/{user_id}",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(get_key_type)],
|
||||
)
|
||||
async def api_usermanager_user(user_id):
|
||||
user = await get_usermanager_user(user_id)
|
||||
return user.dict()
|
||||
return user.dict() if user else None
|
||||
|
||||
|
||||
@usermanager_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED)
|
||||
async def api_usermanager_users_create(
|
||||
data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@usermanager_ext.post(
|
||||
"/api/v1/users",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
dependencies=[Depends(get_key_type)],
|
||||
)
|
||||
async def api_usermanager_users_create(data: CreateUserData):
|
||||
user = await create_usermanager_user(data)
|
||||
full = user.dict()
|
||||
full["wallets"] = [
|
||||
|
@ -50,11 +56,12 @@ async def api_usermanager_users_create(
|
|||
return full
|
||||
|
||||
|
||||
@usermanager_ext.delete("/api/v1/users/{user_id}")
|
||||
@usermanager_ext.delete(
|
||||
"/api/v1/users/{user_id}", dependencies=[Depends(require_admin_key)]
|
||||
)
|
||||
async def api_usermanager_users_delete(
|
||||
user_id,
|
||||
delete_core: bool = Query(True),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
user = await get_usermanager_user(user_id)
|
||||
if not user:
|
||||
|
@ -84,10 +91,8 @@ async def api_usermanager_activate_extension(
|
|||
# Wallets
|
||||
|
||||
|
||||
@usermanager_ext.post("/api/v1/wallets")
|
||||
async def api_usermanager_wallets_create(
|
||||
data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@usermanager_ext.post("/api/v1/wallets", dependencies=[Depends(get_key_type)])
|
||||
async def api_usermanager_wallets_create(data: CreateUserWallet):
|
||||
user = await create_usermanager_wallet(
|
||||
user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id
|
||||
)
|
||||
|
@ -95,31 +100,33 @@ async def api_usermanager_wallets_create(
|
|||
|
||||
|
||||
@usermanager_ext.get("/api/v1/wallets")
|
||||
async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
async def api_usermanager_wallets(
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
):
|
||||
admin_id = wallet.wallet.user
|
||||
return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)]
|
||||
|
||||
|
||||
@usermanager_ext.get("/api/v1/transactions/{wallet_id}")
|
||||
async def api_usermanager_wallet_transactions(
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
@usermanager_ext.get(
|
||||
"/api/v1/transactions/{wallet_id}", dependencies=[Depends(get_key_type)]
|
||||
)
|
||||
async def api_usermanager_wallet_transactions(wallet_id):
|
||||
return await get_usermanager_wallet_transactions(wallet_id)
|
||||
|
||||
|
||||
@usermanager_ext.get("/api/v1/wallets/{user_id}")
|
||||
async def api_usermanager_users_wallets(
|
||||
user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
@usermanager_ext.get(
|
||||
"/api/v1/wallets/{user_id}", dependencies=[Depends(require_admin_key)]
|
||||
)
|
||||
async def api_usermanager_users_wallets(user_id):
|
||||
return [
|
||||
s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id)
|
||||
]
|
||||
|
||||
|
||||
@usermanager_ext.delete("/api/v1/wallets/{wallet_id}")
|
||||
async def api_usermanager_wallets_delete(
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
@usermanager_ext.delete(
|
||||
"/api/v1/wallets/{wallet_id}", dependencies=[Depends(require_admin_key)]
|
||||
)
|
||||
async def api_usermanager_wallets_delete(wallet_id):
|
||||
get_wallet = await get_usermanager_wallet(wallet_id)
|
||||
if not get_wallet:
|
||||
raise HTTPException(
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.
|
||||
|
||||
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
|
||||
You can now use this wallet on the LNbits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
|
||||
|
||||
<a href="https://www.youtube.com/watch?v=rQMHzQEPwZY">Video demo</a>
|
||||
|
||||
|
|
|
@ -76,9 +76,13 @@ class CreatePsbt(BaseModel):
|
|||
tx_size: int
|
||||
|
||||
|
||||
class SerializedTransaction(BaseModel):
|
||||
tx_hex: str
|
||||
|
||||
|
||||
class ExtractPsbt(BaseModel):
|
||||
psbtBase64 = "" # // todo snake case
|
||||
inputs: List[TransactionInput]
|
||||
inputs: List[SerializedTransaction]
|
||||
network = "Mainnet"
|
||||
|
||||
|
||||
|
@ -87,10 +91,6 @@ class SignedTransaction(BaseModel):
|
|||
tx_json: Optional[str]
|
||||
|
||||
|
||||
class BroadcastTransaction(BaseModel):
|
||||
tx_hex: str
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
mempool_endpoint = "https://mempool.space"
|
||||
receive_gap_limit = 20
|
||||
|
|
|
@ -272,15 +272,35 @@ async function payment(path) {
|
|||
this.showChecking = false
|
||||
}
|
||||
},
|
||||
|
||||
fetchUtxoHexForPsbt: async function (psbtBase64) {
|
||||
if (this.tx?.inputs && this.tx?.inputs.length) return this.tx.inputs
|
||||
|
||||
const {data: psbtUtxos} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/psbt/utxos',
|
||||
this.adminkey,
|
||||
{psbtBase64}
|
||||
)
|
||||
|
||||
const inputs = []
|
||||
for (const utxo of psbtUtxos) {
|
||||
const txHex = await this.fetchTxHex(utxo.tx_id)
|
||||
inputs.push({tx_hex: txHex})
|
||||
}
|
||||
return inputs
|
||||
},
|
||||
extractTxFromPsbt: async function (psbtBase64) {
|
||||
try {
|
||||
const inputs = await this.fetchUtxoHexForPsbt(psbtBase64)
|
||||
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/watchonly/api/v1/psbt/extract',
|
||||
this.adminkey,
|
||||
{
|
||||
psbtBase64,
|
||||
inputs: this.tx.inputs,
|
||||
inputs,
|
||||
network: this.network
|
||||
}
|
||||
)
|
||||
|
|
|
@ -54,7 +54,10 @@ const watchOnly = async () => {
|
|||
showPayment: false,
|
||||
fetchedUtxos: false,
|
||||
utxosFilter: '',
|
||||
network: null
|
||||
network: null,
|
||||
|
||||
showEnterSignedPsbt: false,
|
||||
signedBase64Psbt: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -173,6 +176,15 @@ const watchOnly = async () => {
|
|||
this.$refs.paymentRef.updateSignedPsbt(psbtBase64)
|
||||
},
|
||||
|
||||
showEnterSignedPsbtDialog: function () {
|
||||
this.signedBase64Psbt = ''
|
||||
this.showEnterSignedPsbt = true
|
||||
},
|
||||
|
||||
checkPsbt: function () {
|
||||
this.$refs.paymentRef.updateSignedPsbt(this.signedBase64Psbt)
|
||||
},
|
||||
|
||||
//################### UTXOs ###################
|
||||
scanAllAddresses: async function () {
|
||||
await this.refreshAddresses()
|
||||
|
|
|
@ -52,14 +52,38 @@
|
|||
></q-spinner>
|
||||
</div>
|
||||
<div class="col-md-3 col-sm-5 q-pr-md">
|
||||
<q-btn
|
||||
<q-btn-dropdown
|
||||
v-if="!showPayment"
|
||||
split
|
||||
unelevated
|
||||
label="New Payment"
|
||||
color="secondary"
|
||||
class="btn-full"
|
||||
@click="goToPaymentView"
|
||||
>New Payment</q-btn
|
||||
>
|
||||
<q-list>
|
||||
<q-item @click="goToPaymentView" clickable v-close-popup>
|
||||
<q-item-section>
|
||||
<q-item-label>New Payment</q-item-label>
|
||||
<q-item-label caption
|
||||
>Create a new payment by selecting Inputs and
|
||||
Outputs</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
@click="showEnterSignedPsbtDialog"
|
||||
clickable
|
||||
v-close-popup
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>From Signed PSBT</q-item-label>
|
||||
<q-item-label caption> Paste a signed PSBT</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
|
||||
<q-btn
|
||||
v-if="showPayment"
|
||||
outline
|
||||
|
@ -226,6 +250,36 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="showEnterSignedPsbt" position="top">
|
||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
||||
<h5 class="text-subtitle1 q-my-none">Enter the Signed PSBT</h5>
|
||||
<q-separator></q-separator><br />
|
||||
|
||||
<p>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="signedBase64Psbt"
|
||||
type="textarea"
|
||||
label="Signed PSBT"
|
||||
></q-input>
|
||||
</p>
|
||||
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
v-close-popup
|
||||
color="grey"
|
||||
@click="checkPsbt"
|
||||
class="q-ml-sm"
|
||||
>Check PSBT</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
<div class="row q-mt-lg q-gutter-sm"></div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% endraw %}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -31,11 +31,11 @@ from .crud import (
|
|||
)
|
||||
from .helpers import parse_key
|
||||
from .models import (
|
||||
BroadcastTransaction,
|
||||
Config,
|
||||
CreatePsbt,
|
||||
CreateWallet,
|
||||
ExtractPsbt,
|
||||
SerializedTransaction,
|
||||
SignedTransaction,
|
||||
WalletAccount,
|
||||
)
|
||||
|
@ -291,6 +291,24 @@ async def api_psbt_create(
|
|||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@watchonly_ext.put("/api/v1/psbt/utxos")
|
||||
async def api_psbt_extract_tx(
|
||||
req: Request, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
"""Extract previous unspent transaction outputs (tx_id, vout) from PSBT"""
|
||||
|
||||
body = await req.json()
|
||||
try:
|
||||
psbt = PSBT.from_base64(body["psbtBase64"])
|
||||
res = []
|
||||
for _, inp in enumerate(psbt.inputs):
|
||||
res.append({"tx_id": inp.txid.hex(), "vout": inp.vout})
|
||||
|
||||
return res
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@watchonly_ext.put("/api/v1/psbt/extract")
|
||||
async def api_psbt_extract_tx(
|
||||
data: ExtractPsbt, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
|
@ -327,7 +345,7 @@ async def api_psbt_extract_tx(
|
|||
|
||||
@watchonly_ext.post("/api/v1/tx")
|
||||
async def api_tx_broadcast(
|
||||
data: BroadcastTransaction, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
data: SerializedTransaction, w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
try:
|
||||
config = await get_config(w.wallet.user)
|
||||
|
|
|
@ -14,7 +14,7 @@ LNURL withdraw is a **very powerful tool** and should not have his use limited t
|
|||
|
||||
#### Quick Vouchers
|
||||
|
||||
LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc...
|
||||
LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc...
|
||||
|
||||
1. Create Quick Vouchers\
|
||||
![quick vouchers](https://i.imgur.com/IUfwdQz.jpg)
|
||||
|
@ -37,12 +37,12 @@ LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t
|
|||
- set a title for the LNURLw (it will show up in users wallet)
|
||||
- define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value
|
||||
- set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times
|
||||
- LNBits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans
|
||||
- LNbits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans
|
||||
- you can set the time in _seconds, minutes or hours_
|
||||
- the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned
|
||||
2. Print, share or display your LNURLw link or it's QR code\
|
||||
![lnurlw created](https://i.imgur.com/X00twiX.jpg)
|
||||
|
||||
**LNBits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNBits wallet!
|
||||
**LNbits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNbits wallet!
|
||||
|
||||
![](https://i.imgur.com/2zZ7mi8.jpg)
|
||||
|
|
|
@ -6,9 +6,9 @@ from typing import Any, List, NamedTuple, Optional
|
|||
import jinja2
|
||||
import shortuuid # type: ignore
|
||||
|
||||
import lnbits.settings as settings
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class Extension(NamedTuple):
|
||||
|
@ -26,12 +26,10 @@ class Extension(NamedTuple):
|
|||
|
||||
class ExtensionManager:
|
||||
def __init__(self):
|
||||
self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS
|
||||
self._admin_only: List[str] = [
|
||||
x.strip(" ") for x in settings.LNBITS_ADMIN_EXTENSIONS
|
||||
]
|
||||
self._disabled: List[str] = settings.lnbits_disabled_extensions
|
||||
self._admin_only: List[str] = settings.lnbits_admin_extensions
|
||||
self._extension_folders: List[str] = [
|
||||
x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions"))
|
||||
x[1] for x in os.walk(os.path.join(settings.lnbits_path, "extensions"))
|
||||
][0]
|
||||
|
||||
@property
|
||||
|
@ -47,7 +45,7 @@ class ExtensionManager:
|
|||
try:
|
||||
with open(
|
||||
os.path.join(
|
||||
settings.LNBITS_PATH, "extensions", extension, "config.json"
|
||||
settings.lnbits_path, "extensions", extension, "config.json"
|
||||
)
|
||||
) as json_file:
|
||||
config = json.load(json_file)
|
||||
|
@ -121,7 +119,7 @@ def get_css_vendored(prefer_minified: bool = False) -> List[str]:
|
|||
def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
|
||||
paths: List[str] = []
|
||||
for path in glob.glob(
|
||||
os.path.join(settings.LNBITS_PATH, "static/vendor/**"), recursive=True
|
||||
os.path.join(settings.lnbits_path, "static/vendor/**"), recursive=True
|
||||
):
|
||||
if path.endswith(".min" + ext):
|
||||
# path is minified
|
||||
|
@ -147,7 +145,7 @@ def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
|
|||
|
||||
|
||||
def url_for_vendored(abspath: str) -> str:
|
||||
return "/" + os.path.relpath(abspath, settings.LNBITS_PATH)
|
||||
return "/" + os.path.relpath(abspath, settings.lnbits_path)
|
||||
|
||||
|
||||
def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
|
||||
|
@ -160,27 +158,29 @@ def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> s
|
|||
|
||||
|
||||
def template_renderer(additional_folders: List = []) -> Jinja2Templates:
|
||||
|
||||
t = Jinja2Templates(
|
||||
loader=jinja2.FileSystemLoader(
|
||||
["lnbits/templates", "lnbits/core/templates", *additional_folders]
|
||||
)
|
||||
)
|
||||
|
||||
if settings.LNBITS_AD_SPACE:
|
||||
t.env.globals["AD_TITLE"] = settings.LNBITS_AD_SPACE_TITLE
|
||||
t.env.globals["AD_SPACE"] = settings.LNBITS_AD_SPACE
|
||||
t.env.globals["HIDE_API"] = settings.LNBITS_HIDE_API
|
||||
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE
|
||||
t.env.globals["LNBITS_DENOMINATION"] = settings.LNBITS_DENOMINATION
|
||||
t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE
|
||||
t.env.globals["SITE_DESCRIPTION"] = settings.LNBITS_SITE_DESCRIPTION
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS
|
||||
t.env.globals["LNBITS_VERSION"] = settings.LNBITS_COMMIT
|
||||
t.env.globals["EXTENSIONS"] = get_valid_extensions()
|
||||
if settings.LNBITS_CUSTOM_LOGO:
|
||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.LNBITS_CUSTOM_LOGO
|
||||
if settings.lnbits_ad_space_enabled:
|
||||
t.env.globals["AD_SPACE"] = settings.lnbits_ad_space.split(",")
|
||||
t.env.globals["AD_SPACE_TITLE"] = settings.lnbits_ad_space_title
|
||||
|
||||
if settings.DEBUG:
|
||||
t.env.globals["HIDE_API"] = settings.lnbits_hide_api
|
||||
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
|
||||
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
|
||||
t.env.globals["SITE_TAGLINE"] = settings.lnbits_site_tagline
|
||||
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
|
||||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
|
||||
t.env.globals["LNBITS_VERSION"] = settings.lnbits_commit
|
||||
t.env.globals["EXTENSIONS"] = get_valid_extensions()
|
||||
if settings.lnbits_custom_logo:
|
||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
|
||||
|
||||
if settings.debug:
|
||||
t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored())
|
||||
t.env.globals["VENDORED_CSS"] = map(url_for_vendored, get_css_vendored())
|
||||
else:
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
import uvloop
|
||||
|
||||
uvloop.install()
|
||||
|
||||
import multiprocessing as mp
|
||||
import time
|
||||
|
||||
import click
|
||||
import uvicorn
|
||||
|
||||
from lnbits.settings import FORWARDED_ALLOW_IPS, HOST, PORT
|
||||
from lnbits.settings import set_cli_settings, settings
|
||||
|
||||
|
||||
@click.command(
|
||||
|
@ -10,10 +17,12 @@ from lnbits.settings import FORWARDED_ALLOW_IPS, HOST, PORT
|
|||
allow_extra_args=True,
|
||||
)
|
||||
)
|
||||
@click.option("--port", default=PORT, help="Port to listen on")
|
||||
@click.option("--host", default=HOST, help="Host to run LNBits on")
|
||||
@click.option("--port", default=settings.port, help="Port to listen on")
|
||||
@click.option("--host", default=settings.host, help="Host to run LNBits on")
|
||||
@click.option(
|
||||
"--forwarded-allow-ips", default=FORWARDED_ALLOW_IPS, help="Allowed proxy servers"
|
||||
"--forwarded-allow-ips",
|
||||
default=settings.forwarded_allow_ips,
|
||||
help="Allowed proxy servers",
|
||||
)
|
||||
@click.option("--ssl-keyfile", default=None, help="Path to SSL keyfile")
|
||||
@click.option("--ssl-certfile", default=None, help="Path to SSL certificate")
|
||||
|
@ -27,6 +36,9 @@ def main(
|
|||
ssl_certfile: str,
|
||||
):
|
||||
"""Launched with `poetry run lnbits` at root level"""
|
||||
|
||||
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)
|
||||
|
||||
# this beautiful beast parses all command line arguments and passes them to the uvicorn server
|
||||
d = dict()
|
||||
for a in ctx.args:
|
||||
|
@ -41,18 +53,32 @@ def main(
|
|||
else:
|
||||
d[a.strip("--")] = True # argument like --key
|
||||
|
||||
config = uvicorn.Config(
|
||||
"lnbits.__main__:app",
|
||||
port=port,
|
||||
host=host,
|
||||
forwarded_allow_ips=forwarded_allow_ips,
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_certfile=ssl_certfile,
|
||||
**d
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
server.run()
|
||||
while True:
|
||||
config = uvicorn.Config(
|
||||
"lnbits.__main__:app",
|
||||
loop="uvloop",
|
||||
port=port,
|
||||
host=host,
|
||||
forwarded_allow_ips=forwarded_allow_ips,
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_certfile=ssl_certfile,
|
||||
**d
|
||||
)
|
||||
|
||||
server = uvicorn.Server(config=config)
|
||||
process = mp.Process(target=server.run)
|
||||
process.start()
|
||||
server_restart.wait()
|
||||
server_restart.clear()
|
||||
server.should_exit = True
|
||||
server.force_exit = True
|
||||
time.sleep(3)
|
||||
process.terminate()
|
||||
process.join()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
server_restart = mp.Event()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
@ -1,92 +1,335 @@
|
|||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import subprocess
|
||||
from os import path
|
||||
from typing import List
|
||||
from sqlite3 import Row
|
||||
from typing import List, Optional
|
||||
|
||||
from environs import Env # type: ignore
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseSettings, Field, validator
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
wallets_module = importlib.import_module("lnbits.wallets")
|
||||
wallet_class = getattr(
|
||||
wallets_module, env.str("LNBITS_BACKEND_WALLET_CLASS", default="VoidWallet")
|
||||
)
|
||||
def list_parse_fallback(v):
|
||||
try:
|
||||
return json.loads(v)
|
||||
except Exception:
|
||||
replaced = v.replace(" ", "")
|
||||
if replaced:
|
||||
return replaced.split(",")
|
||||
else:
|
||||
return []
|
||||
|
||||
DEBUG = env.bool("DEBUG", default=False)
|
||||
|
||||
HOST = env.str("HOST", default="127.0.0.1")
|
||||
PORT = env.int("PORT", default=5000)
|
||||
class LNbitsSettings(BaseSettings):
|
||||
def validate(cls, val):
|
||||
if type(val) == str:
|
||||
val = val.split(",") if val else []
|
||||
return val
|
||||
|
||||
FORWARDED_ALLOW_IPS = env.str("FORWARDED_ALLOW_IPS", default="127.0.0.1")
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback
|
||||
|
||||
LNBITS_PATH = path.dirname(path.realpath(__file__))
|
||||
LNBITS_DATA_FOLDER = env.str(
|
||||
"LNBITS_DATA_FOLDER", default=path.join(LNBITS_PATH, "data")
|
||||
)
|
||||
LNBITS_DATABASE_URL = env.str("LNBITS_DATABASE_URL", default=None)
|
||||
|
||||
LNBITS_ALLOWED_USERS: List[str] = [
|
||||
x.strip(" ") for x in env.list("LNBITS_ALLOWED_USERS", default=[], subcast=str)
|
||||
]
|
||||
LNBITS_ADMIN_USERS: List[str] = [
|
||||
x.strip(" ") for x in env.list("LNBITS_ADMIN_USERS", default=[], subcast=str)
|
||||
]
|
||||
LNBITS_ADMIN_EXTENSIONS: List[str] = [
|
||||
x.strip(" ") for x in env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str)
|
||||
]
|
||||
LNBITS_DISABLED_EXTENSIONS: List[str] = [
|
||||
x.strip(" ")
|
||||
for x in env.list("LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str)
|
||||
]
|
||||
class UsersSettings(LNbitsSettings):
|
||||
lnbits_admin_users: List[str] = Field(default=[])
|
||||
lnbits_allowed_users: List[str] = Field(default=[])
|
||||
lnbits_admin_extensions: List[str] = Field(default=[])
|
||||
lnbits_disabled_extensions: List[str] = Field(default=[])
|
||||
|
||||
LNBITS_AD_SPACE_TITLE = env.str(
|
||||
"LNBITS_AD_SPACE_TITLE", default="Optional Advert Space"
|
||||
)
|
||||
LNBITS_AD_SPACE = [x.strip(" ") for x in env.list("LNBITS_AD_SPACE", default=[])]
|
||||
LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False)
|
||||
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
|
||||
LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats")
|
||||
LNBITS_SITE_TAGLINE = env.str(
|
||||
"LNBITS_SITE_TAGLINE", default="free and open-source lightning wallet"
|
||||
)
|
||||
LNBITS_SITE_DESCRIPTION = env.str("LNBITS_SITE_DESCRIPTION", default="")
|
||||
LNBITS_THEME_OPTIONS: List[str] = [
|
||||
x.strip(" ")
|
||||
for x in env.list(
|
||||
"LNBITS_THEME_OPTIONS",
|
||||
default="classic, flamingo, mint, salvador, monochrome, autumn",
|
||||
subcast=str,
|
||||
|
||||
class ThemesSettings(LNbitsSettings):
|
||||
lnbits_site_title: str = Field(default="LNbits")
|
||||
lnbits_site_tagline: str = Field(default="free and open-source lightning wallet")
|
||||
lnbits_site_description: str = Field(default=None)
|
||||
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
|
||||
lnbits_theme_options: List[str] = Field(
|
||||
default=["classic", "flamingo", "mint", "salvador", "monochrome", "autumn"]
|
||||
)
|
||||
]
|
||||
LNBITS_CUSTOM_LOGO = env.str("LNBITS_CUSTOM_LOGO", default="")
|
||||
lnbits_custom_logo: str = Field(default=None)
|
||||
lnbits_ad_space_title: str = Field(default="Supported by")
|
||||
lnbits_ad_space: str = Field(
|
||||
default="https://shop.lnbits.com/;/static/images/lnbits-shop-light.png;/static/images/lnbits-shop-dark.png"
|
||||
) # sneaky sneaky
|
||||
lnbits_ad_space_enabled: bool = Field(default=False)
|
||||
|
||||
WALLET = wallet_class()
|
||||
FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
|
||||
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
|
||||
PREFER_SECURE_URLS = env.bool("LNBITS_FORCE_HTTPS", default=True)
|
||||
|
||||
RESERVE_FEE_MIN = env.int("LNBITS_RESERVE_FEE_MIN", default=2000)
|
||||
RESERVE_FEE_PERCENT = env.float("LNBITS_RESERVE_FEE_PERCENT", default=1.0)
|
||||
SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0)
|
||||
class OpsSettings(LNbitsSettings):
|
||||
lnbits_force_https: bool = Field(default=False)
|
||||
lnbits_reserve_fee_min: int = Field(default=2000)
|
||||
lnbits_reserve_fee_percent: float = Field(default=1.0)
|
||||
lnbits_service_fee: float = Field(default=0)
|
||||
lnbits_hide_api: bool = Field(default=False)
|
||||
lnbits_denomination: str = Field(default="sats")
|
||||
|
||||
|
||||
class FakeWalletFundingSource(LNbitsSettings):
|
||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||
|
||||
|
||||
class LNbitsFundingSource(LNbitsSettings):
|
||||
lnbits_endpoint: str = Field(default="https://legend.lnbits.com")
|
||||
lnbits_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class ClicheFundingSource(LNbitsSettings):
|
||||
cliche_endpoint: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class CoreLightningFundingSource(LNbitsSettings):
|
||||
corelightning_rpc: Optional[str] = Field(default=None)
|
||||
clightning_rpc: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class EclairFundingSource(LNbitsSettings):
|
||||
eclair_url: Optional[str] = Field(default=None)
|
||||
eclair_pass: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class LndRestFundingSource(LNbitsSettings):
|
||||
lnd_rest_endpoint: Optional[str] = Field(default=None)
|
||||
lnd_rest_cert: Optional[str] = Field(default=None)
|
||||
lnd_rest_macaroon: Optional[str] = Field(default=None)
|
||||
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
|
||||
lnd_cert: Optional[str] = Field(default=None)
|
||||
lnd_admin_macaroon: Optional[str] = Field(default=None)
|
||||
lnd_invoice_macaroon: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class LndGrpcFundingSource(LNbitsSettings):
|
||||
lnd_grpc_endpoint: Optional[str] = Field(default=None)
|
||||
lnd_grpc_cert: Optional[str] = Field(default=None)
|
||||
lnd_grpc_port: Optional[int] = Field(default=None)
|
||||
lnd_grpc_admin_macaroon: Optional[str] = Field(default=None)
|
||||
lnd_grpc_invoice_macaroon: Optional[str] = Field(default=None)
|
||||
lnd_grpc_macaroon: Optional[str] = Field(default=None)
|
||||
lnd_grpc_macaroon_encrypted: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class LnPayFundingSource(LNbitsSettings):
|
||||
lnpay_api_endpoint: Optional[str] = Field(default=None)
|
||||
lnpay_api_key: Optional[str] = Field(default=None)
|
||||
lnpay_wallet_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class LnTxtBotFundingSource(LNbitsSettings):
|
||||
lntxbot_api_endpoint: Optional[str] = Field(default=None)
|
||||
lntxbot_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class OpenNodeFundingSource(LNbitsSettings):
|
||||
opennode_api_endpoint: Optional[str] = Field(default=None)
|
||||
opennode_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class SparkFundingSource(LNbitsSettings):
|
||||
spark_url: Optional[str] = Field(default=None)
|
||||
spark_token: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class LnTipsFundingSource(LNbitsSettings):
|
||||
lntips_api_endpoint: Optional[str] = Field(default=None)
|
||||
lntips_api_key: Optional[str] = Field(default=None)
|
||||
lntips_admin_key: Optional[str] = Field(default=None)
|
||||
lntips_invoice_key: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
# todo: must be extracted
|
||||
class BoltzExtensionSettings(LNbitsSettings):
|
||||
boltz_network: str = Field(default="main")
|
||||
boltz_url: str = Field(default="https://boltz.exchange/api")
|
||||
boltz_mempool_space_url: str = Field(default="https://mempool.space")
|
||||
boltz_mempool_space_url_ws: str = Field(default="wss://mempool.space")
|
||||
|
||||
|
||||
class FundingSourcesSettings(
|
||||
FakeWalletFundingSource,
|
||||
LNbitsFundingSource,
|
||||
ClicheFundingSource,
|
||||
CoreLightningFundingSource,
|
||||
EclairFundingSource,
|
||||
LndRestFundingSource,
|
||||
LndGrpcFundingSource,
|
||||
LnPayFundingSource,
|
||||
LnTxtBotFundingSource,
|
||||
OpenNodeFundingSource,
|
||||
SparkFundingSource,
|
||||
LnTipsFundingSource,
|
||||
):
|
||||
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
|
||||
|
||||
|
||||
class EditableSettings(
|
||||
UsersSettings,
|
||||
ThemesSettings,
|
||||
OpsSettings,
|
||||
FundingSourcesSettings,
|
||||
BoltzExtensionSettings,
|
||||
):
|
||||
@validator(
|
||||
"lnbits_admin_users",
|
||||
"lnbits_allowed_users",
|
||||
"lnbits_theme_options",
|
||||
"lnbits_admin_extensions",
|
||||
"lnbits_disabled_extensions",
|
||||
pre=True,
|
||||
)
|
||||
def validate_editable_settings(cls, val):
|
||||
return super().validate(cls, val)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
return cls(
|
||||
**{k: v for k, v in d.items() if k in inspect.signature(cls).parameters}
|
||||
)
|
||||
|
||||
|
||||
class EnvSettings(LNbitsSettings):
|
||||
debug: bool = Field(default=False)
|
||||
host: str = Field(default="127.0.0.1")
|
||||
port: int = Field(default=5000)
|
||||
forwarded_allow_ips: str = Field(default="*")
|
||||
lnbits_path: str = Field(default=".")
|
||||
lnbits_commit: str = Field(default="unknown")
|
||||
super_user: str = Field(default="")
|
||||
|
||||
|
||||
class SaaSSettings(LNbitsSettings):
|
||||
lnbits_saas_callback: Optional[str] = Field(default=None)
|
||||
lnbits_saas_secret: Optional[str] = Field(default=None)
|
||||
lnbits_saas_instance_id: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class PersistenceSettings(LNbitsSettings):
|
||||
lnbits_data_folder: str = Field(default="./data")
|
||||
lnbits_database_url: str = Field(default=None)
|
||||
|
||||
|
||||
class SuperUserSettings(LNbitsSettings):
|
||||
lnbits_allowed_funding_sources: List[str] = Field(
|
||||
default=[
|
||||
"VoidWallet",
|
||||
"FakeWallet",
|
||||
"CLightningWallet",
|
||||
"LndRestWallet",
|
||||
"LndWallet",
|
||||
"LntxbotWallet",
|
||||
"LNPayWallet",
|
||||
"LNbitsWallet",
|
||||
"OpenNodeWallet",
|
||||
"LnTipsWallet",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ReadOnlySettings(
|
||||
EnvSettings, SaaSSettings, PersistenceSettings, SuperUserSettings
|
||||
):
|
||||
lnbits_admin_ui: bool = Field(default=False)
|
||||
|
||||
@validator(
|
||||
"lnbits_allowed_funding_sources",
|
||||
pre=True,
|
||||
)
|
||||
def validate_readonly_settings(cls, val):
|
||||
return super().validate(cls, val)
|
||||
|
||||
@classmethod
|
||||
def readonly_fields(cls):
|
||||
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
|
||||
|
||||
|
||||
class Settings(EditableSettings, ReadOnlySettings):
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Settings":
|
||||
data = dict(row)
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class SuperSettings(EditableSettings):
|
||||
super_user: str
|
||||
|
||||
|
||||
class AdminSettings(EditableSettings):
|
||||
super_user: bool
|
||||
lnbits_allowed_funding_sources: Optional[List[str]]
|
||||
|
||||
|
||||
def set_cli_settings(**kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
# set wallet class after settings are loaded
|
||||
def set_wallet_class():
|
||||
wallet_class = getattr(wallets_module, settings.lnbits_backend_wallet_class)
|
||||
global WALLET
|
||||
WALLET = wallet_class()
|
||||
|
||||
|
||||
def get_wallet_class():
|
||||
# wallet_class = getattr(wallets_module, settings.lnbits_backend_wallet_class)
|
||||
return WALLET
|
||||
|
||||
|
||||
def send_admin_user_to_saas():
|
||||
if settings.lnbits_saas_callback:
|
||||
with httpx.Client() as client:
|
||||
headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"X-API-KEY": settings.lnbits_saas_secret,
|
||||
}
|
||||
payload = {
|
||||
"instance_id": settings.lnbits_saas_instance_id,
|
||||
"adminuser": settings.super_user,
|
||||
}
|
||||
try:
|
||||
client.post(
|
||||
settings.lnbits_saas_callback,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
logger.success("sent super_user to saas application")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"error sending super_user to saas: {settings.lnbits_saas_callback}. Error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
############### INIT #################
|
||||
|
||||
readonly_variables = ReadOnlySettings.readonly_fields()
|
||||
|
||||
settings = Settings()
|
||||
|
||||
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
|
||||
|
||||
try:
|
||||
LNBITS_COMMIT = (
|
||||
settings.lnbits_commit = (
|
||||
subprocess.check_output(
|
||||
["git", "-C", LNBITS_PATH, "rev-parse", "HEAD"], stderr=subprocess.DEVNULL
|
||||
["git", "-C", settings.lnbits_path, "rev-parse", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
.strip()
|
||||
.decode("ascii")
|
||||
)
|
||||
except:
|
||||
LNBITS_COMMIT = "unknown"
|
||||
settings.lnbits_commit = "docker"
|
||||
|
||||
|
||||
BOLTZ_NETWORK = env.str("BOLTZ_NETWORK", default="main")
|
||||
BOLTZ_URL = env.str("BOLTZ_URL", default="https://boltz.exchange/api")
|
||||
BOLTZ_MEMPOOL_SPACE_URL = env.str(
|
||||
"BOLTZ_MEMPOOL_SPACE_URL", default="https://mempool.space"
|
||||
)
|
||||
BOLTZ_MEMPOOL_SPACE_URL_WS = env.str(
|
||||
"BOLTZ_MEMPOOL_SPACE_URL_WS", default="wss://mempool.space"
|
||||
)
|
||||
# printing enviroment variable for debugging
|
||||
if not settings.lnbits_admin_ui:
|
||||
logger.debug(f"Enviroment Settings:")
|
||||
for key, value in settings.dict(exclude_none=True).items():
|
||||
logger.debug(f"{key}: {value}")
|
||||
|
||||
|
||||
wallets_module = importlib.import_module("lnbits.wallets")
|
||||
FAKE_WALLET = getattr(wallets_module, "FakeWallet")()
|
||||
|
||||
# initialize as fake wallet
|
||||
WALLET = FAKE_WALLET
|
||||
|
|
|
@ -138,6 +138,7 @@ window.LNbits = {
|
|||
user: function (data) {
|
||||
var obj = {
|
||||
id: data.id,
|
||||
admin: data.admin,
|
||||
email: data.email,
|
||||
extensions: data.extensions,
|
||||
wallets: data.wallets
|
||||
|
@ -184,6 +185,7 @@ window.LNbits = {
|
|||
bolt11: data.bolt11,
|
||||
preimage: data.preimage,
|
||||
payment_hash: data.payment_hash,
|
||||
expiry: data.expiry,
|
||||
extra: data.extra,
|
||||
wallet_id: data.wallet_id,
|
||||
webhook: data.webhook,
|
||||
|
@ -195,6 +197,11 @@ window.LNbits = {
|
|||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.dateFrom = moment(obj.date).fromNow()
|
||||
obj.expirydate = Quasar.utils.date.formatDate(
|
||||
new Date(obj.expiry * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.expirydateFrom = moment(obj.expirydate).fromNow()
|
||||
obj.msat = obj.amount
|
||||
obj.sat = obj.msat / 1000
|
||||
obj.tag = obj.extra.tag
|
||||
|
|
|
@ -177,6 +177,34 @@ Vue.component('lnbits-extension-list', {
|
|||
}
|
||||
})
|
||||
|
||||
Vue.component('lnbits-admin-ui', {
|
||||
data: function () {
|
||||
return {
|
||||
extensions: [],
|
||||
user: null
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<q-list v-if="user && user.admin" dense class="lnbits-drawer__q-list">
|
||||
<q-item-label header>Admin</q-item-label>
|
||||
<q-item clickable tag="a" :href="['/admin?usr=', user.id].join('')">
|
||||
<q-item-section side>
|
||||
<q-icon name="admin_panel_settings" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1" class="text-caption">Manage Server</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
`,
|
||||
|
||||
created: function () {
|
||||
if (window.user) {
|
||||
this.user = LNbits.map.user(window.user)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('lnbits-payment-details', {
|
||||
props: ['payment'],
|
||||
data: function () {
|
||||
|
@ -192,9 +220,13 @@ Vue.component('lnbits-payment-details', {
|
|||
</q-badge>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-3"><b>Date</b>:</div>
|
||||
<div class="col-3"><b>Created</b>:</div>
|
||||
<div class="col-9">{{ payment.date }} ({{ payment.dateFrom }})</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-3"><b>Expiry</b>:</div>
|
||||
<div class="col-9">{{ payment.expirydate }} ({{ payment.expirydateFrom }})</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-3"><b>Description</b>:</div>
|
||||
<div class="col-9">{{ payment.memo }}</div>
|
||||
|
|
|
@ -15,7 +15,7 @@ from lnbits.core.crud import (
|
|||
get_standalone_payment,
|
||||
)
|
||||
from lnbits.core.services import redeem_lnurl_withdraw
|
||||
from lnbits.settings import WALLET
|
||||
from lnbits.settings import get_wallet_class
|
||||
|
||||
from .core import db
|
||||
|
||||
|
@ -79,6 +79,7 @@ async def webhook_handler():
|
|||
"""
|
||||
Returns the webhook_handler for the selected wallet if present. Used by API.
|
||||
"""
|
||||
WALLET = get_wallet_class()
|
||||
handler = getattr(WALLET, "webhook_listener", None)
|
||||
if handler:
|
||||
return await handler()
|
||||
|
@ -108,6 +109,7 @@ async def invoice_listener():
|
|||
|
||||
Called by the app startup sequence.
|
||||
"""
|
||||
WALLET = get_wallet_class()
|
||||
async for checking_id in WALLET.paid_invoices_stream():
|
||||
logger.info("> got a payment notification", checking_id)
|
||||
asyncio.create_task(invoice_callback_dispatcher(checking_id))
|
||||
|
@ -145,7 +147,7 @@ async def check_pending_payments():
|
|||
)
|
||||
# we delete expired invoices once upon the first pending check
|
||||
if incoming:
|
||||
logger.info("Task: deleting all expired invoices")
|
||||
logger.debug("Task: deleting all expired invoices")
|
||||
start_time: float = time.time()
|
||||
await delete_expired_invoices(conn=conn)
|
||||
logger.info(
|
||||
|
|
|
@ -175,6 +175,7 @@
|
|||
:elevated="$q.screen.lt.md"
|
||||
>
|
||||
<lnbits-wallet-list></lnbits-wallet-list>
|
||||
<lnbits-admin-ui></lnbits-admin-ui>
|
||||
<lnbits-extension-list class="q-pb-xl"></lnbits-extension-list>
|
||||
</q-drawer>
|
||||
{% endblock %} {% block page_container %}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from os import getenv
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from websocket import create_connection
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
|
@ -21,7 +22,7 @@ class ClicheWallet(Wallet):
|
|||
"""https://github.com/fiatjaf/cliche"""
|
||||
|
||||
def __init__(self):
|
||||
self.endpoint = getenv("CLICHE_ENDPOINT")
|
||||
self.endpoint = settings.cliche_endpoint
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
|
|
|
@ -8,12 +8,12 @@ import hashlib
|
|||
import random
|
||||
import time
|
||||
from functools import partial, wraps
|
||||
from os import getenv
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11 as lnbits_bolt11
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
|
@ -51,7 +51,7 @@ class CoreLightningWallet(Wallet):
|
|||
"The `pyln-client` library must be installed to use `CoreLightningWallet`."
|
||||
)
|
||||
|
||||
self.rpc = getenv("CORELIGHTNING_RPC") or getenv("CLIGHTNING_RPC")
|
||||
self.rpc = settings.corelightning_rpc or settings.clightning_rpc
|
||||
self.ln = LightningRpc(self.rpc)
|
||||
|
||||
# check if description_hash is supported (from CLN>=v0.11.0)
|
||||
|
|
|
@ -3,7 +3,6 @@ import base64
|
|||
import hashlib
|
||||
import json
|
||||
import urllib.parse
|
||||
from os import getenv
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
@ -18,6 +17,8 @@ from websockets.exceptions import (
|
|||
ConnectionClosedOK,
|
||||
)
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
|
@ -37,12 +38,12 @@ class UnknownError(Exception):
|
|||
|
||||
class EclairWallet(Wallet):
|
||||
def __init__(self):
|
||||
url = getenv("ECLAIR_URL")
|
||||
url = settings.eclair_url
|
||||
self.url = url[:-1] if url.endswith("/") else url
|
||||
|
||||
self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws"
|
||||
|
||||
passw = getenv("ECLAIR_PASS")
|
||||
passw = settings.eclair_pass
|
||||
encodedAuth = base64.b64encode(f":{passw}".encode("utf-8"))
|
||||
auth = str(encodedAuth, "utf-8")
|
||||
self.auth = {"Authorization": f"Basic {auth}"}
|
||||
|
|
|
@ -2,12 +2,12 @@ import asyncio
|
|||
import hashlib
|
||||
import random
|
||||
from datetime import datetime
|
||||
from os import getenv
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
from environs import Env # type: ignore
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..bolt11 import Invoice, decode, encode
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
|
@ -17,13 +17,10 @@ from .base import (
|
|||
Wallet,
|
||||
)
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
|
||||
class FakeWallet(Wallet):
|
||||
queue: asyncio.Queue = asyncio.Queue(0)
|
||||
secret: str = env.str("FAKE_WALLET_SECTRET", default="ToTheMoon1")
|
||||
secret: str = settings.fake_wallet_secret
|
||||
privkey: str = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
secret.encode("utf-8"),
|
||||
|
@ -45,9 +42,6 @@ class FakeWallet(Wallet):
|
|||
description_hash: Optional[bytes] = None,
|
||||
unhashed_description: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
# we set a default secret since FakeWallet is used for internal=True invoices
|
||||
# and the user might not have configured a secret yet
|
||||
|
||||
data: Dict = {
|
||||
"out": False,
|
||||
"amount": amount,
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from os import getenv
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
|
@ -20,12 +21,12 @@ class LNbitsWallet(Wallet):
|
|||
"""https://github.com/lnbits/lnbits"""
|
||||
|
||||
def __init__(self):
|
||||
self.endpoint = getenv("LNBITS_ENDPOINT")
|
||||
self.endpoint = settings.lnbits_endpoint
|
||||
|
||||
key = (
|
||||
getenv("LNBITS_KEY")
|
||||
or getenv("LNBITS_ADMIN_KEY")
|
||||
or getenv("LNBITS_INVOICE_KEY")
|
||||
settings.lnbits_key
|
||||
or settings.lnbits_admin_key
|
||||
or settings.lnbits_invoice_key
|
||||
)
|
||||
self.key = {"X-Api-Key": key}
|
||||
|
||||
|
@ -147,18 +148,26 @@ class LNbitsWallet(Wallet):
|
|||
while True:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None, headers=self.key) as client:
|
||||
async with client.stream("GET", url) as r:
|
||||
del client.headers[
|
||||
"accept-encoding"
|
||||
] # we have to disable compression for SSEs
|
||||
async with client.stream(
|
||||
"GET", url, content="text/event-stream"
|
||||
) as r:
|
||||
sse_trigger = False
|
||||
async for line in r.aiter_lines():
|
||||
if line.startswith("data:"):
|
||||
try:
|
||||
data = json.loads(line[5:])
|
||||
except json.decoder.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if type(data) is not dict:
|
||||
continue
|
||||
|
||||
yield data["payment_hash"] # payment_hash
|
||||
# The data we want to listen to is of this shape:
|
||||
# event: payment-received
|
||||
# data: {.., "payment_hash" : "asd"}
|
||||
if line.startswith("event: payment-received"):
|
||||
sse_trigger = True
|
||||
continue
|
||||
elif sse_trigger and line.startswith("data:"):
|
||||
data = json.loads(line[len("data:") :])
|
||||
sse_trigger = False
|
||||
yield data["payment_hash"]
|
||||
else:
|
||||
sse_trigger = False
|
||||
|
||||
except (OSError, httpx.ReadError, httpx.ConnectError, httpx.ReadTimeout):
|
||||
pass
|
||||
|
|
|
@ -10,7 +10,7 @@ import asyncio
|
|||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
from os import environ, error, getenv
|
||||
from os import environ, error
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
@ -23,6 +23,8 @@ if imports_ok:
|
|||
import lnbits.wallets.lnd_grpc_files.router_pb2 as router
|
||||
import lnbits.wallets.lnd_grpc_files.router_pb2_grpc as routerrpc
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
|
@ -104,20 +106,20 @@ class LndWallet(Wallet):
|
|||
"The `grpcio` and `protobuf` library must be installed to use `GRPC LndWallet`. Alternatively try using the LndRESTWallet."
|
||||
)
|
||||
|
||||
endpoint = getenv("LND_GRPC_ENDPOINT")
|
||||
endpoint = settings.lnd_grpc_endpoint
|
||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
self.port = int(getenv("LND_GRPC_PORT"))
|
||||
self.cert_path = getenv("LND_GRPC_CERT") or getenv("LND_CERT")
|
||||
self.port = int(settings.lnd_grpc_port)
|
||||
self.cert_path = settings.lnd_grpc_cert or settings.lnd_cert
|
||||
|
||||
macaroon = (
|
||||
getenv("LND_GRPC_MACAROON")
|
||||
or getenv("LND_GRPC_ADMIN_MACAROON")
|
||||
or getenv("LND_ADMIN_MACAROON")
|
||||
or getenv("LND_GRPC_INVOICE_MACAROON")
|
||||
or getenv("LND_INVOICE_MACAROON")
|
||||
settings.lnd_grpc_macaroon
|
||||
or settings.lnd_grpc_admin_macaroon
|
||||
or settings.lnd_admin_macaroon
|
||||
or settings.lnd_grpc_invoice_macaroon
|
||||
or settings.lnd_invoice_macaroon
|
||||
)
|
||||
|
||||
encrypted_macaroon = getenv("LND_GRPC_MACAROON_ENCRYPTED")
|
||||
encrypted_macaroon = settings.lnd_grpc_macaroon_encrypted
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||
encrypted_macaroon
|
||||
|
|
|
@ -2,7 +2,6 @@ import asyncio
|
|||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from os import getenv
|
||||
from pydoc import describe
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
|
@ -10,6 +9,7 @@ import httpx
|
|||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11 as lnbits_bolt11
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
|
@ -25,7 +25,7 @@ class LndRestWallet(Wallet):
|
|||
"""https://api.lightning.community/rest/index.html#lnd-rest-api-reference"""
|
||||
|
||||
def __init__(self):
|
||||
endpoint = getenv("LND_REST_ENDPOINT")
|
||||
endpoint = settings.lnd_rest_endpoint
|
||||
endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
endpoint = (
|
||||
"https://" + endpoint if not endpoint.startswith("http") else endpoint
|
||||
|
@ -33,14 +33,14 @@ class LndRestWallet(Wallet):
|
|||
self.endpoint = endpoint
|
||||
|
||||
macaroon = (
|
||||
getenv("LND_REST_MACAROON")
|
||||
or getenv("LND_ADMIN_MACAROON")
|
||||
or getenv("LND_REST_ADMIN_MACAROON")
|
||||
or getenv("LND_INVOICE_MACAROON")
|
||||
or getenv("LND_REST_INVOICE_MACAROON")
|
||||
settings.lnd_rest_macaroon
|
||||
or settings.lnd_admin_macaroon
|
||||
or settings.lnd_rest_admin_macaroon
|
||||
or settings.lnd_invoice_macaroon
|
||||
or settings.lnd_rest_invoice_macaroon
|
||||
)
|
||||
|
||||
encrypted_macaroon = getenv("LND_REST_MACAROON_ENCRYPTED")
|
||||
encrypted_macaroon = settings.lnd_rest_macaroon_encrypted
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||
encrypted_macaroon
|
||||
|
@ -48,7 +48,7 @@ class LndRestWallet(Wallet):
|
|||
self.macaroon = load_macaroon(macaroon)
|
||||
|
||||
self.auth = {"Grpc-Metadata-macaroon": self.macaroon}
|
||||
self.cert = getenv("LND_REST_CERT", True)
|
||||
self.cert = settings.lnd_rest_cert
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
|
|
|
@ -2,13 +2,14 @@ import asyncio
|
|||
import hashlib
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from os import getenv
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi.exceptions import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
|
@ -22,10 +23,10 @@ class LNPayWallet(Wallet):
|
|||
"""https://docs.lnpay.co/"""
|
||||
|
||||
def __init__(self):
|
||||
endpoint = getenv("LNPAY_API_ENDPOINT", "https://lnpay.co/v1")
|
||||
endpoint = settings.lnpay_api_endpoint
|
||||
self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint
|
||||
self.wallet_key = getenv("LNPAY_WALLET_KEY") or getenv("LNPAY_ADMIN_KEY")
|
||||
self.auth = {"X-Api-Key": getenv("LNPAY_API_KEY")}
|
||||
self.wallet_key = settings.lnpay_wallet_key or settings.lnpay_admin_key
|
||||
self.auth = {"X-Api-Key": settings.lnpay_api_key}
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
url = f"{self.endpoint}/wallet/{self.wallet_key}"
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user