commit
cbb909bb3a
202 changed files with 12766 additions and 3970 deletions
20
.env.example
20
.env.example
|
|
@ -6,14 +6,23 @@ PORT=5000
|
||||||
|
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
|
||||||
|
# Allow users and admins by user IDs (comma separated list)
|
||||||
LNBITS_ALLOWED_USERS=""
|
LNBITS_ALLOWED_USERS=""
|
||||||
LNBITS_ADMIN_USERS=""
|
LNBITS_ADMIN_USERS=""
|
||||||
# Extensions only admin can access
|
# 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
|
||||||
|
|
||||||
|
# Restricts access, User IDs seperated by comma
|
||||||
|
LNBITS_ALLOWED_USERS=""
|
||||||
|
|
||||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||||
|
|
||||||
# csv ad image filepaths or urls, extensions can choose to honor
|
# Ad space description
|
||||||
LNBITS_AD_SPACE=""
|
# 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="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
|
# Hides wallet api, extensions can choose to honor
|
||||||
LNBITS_HIDE_API=false
|
LNBITS_HIDE_API=false
|
||||||
|
|
@ -100,3 +109,8 @@ ECLAIR_PASS=eclairpw
|
||||||
# Enter /api in LightningTipBot to get your key
|
# Enter /api in LightningTipBot to get your key
|
||||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||||
|
|
||||||
|
# Cashu Mint
|
||||||
|
# Use a long-enough random (!) private key.
|
||||||
|
# Once set, you cannot change this key as for now.
|
||||||
|
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
|
||||||
|
|
|
||||||
3
.github/workflows/tests.yml
vendored
3
.github/workflows/tests.yml
vendored
|
|
@ -43,9 +43,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
poetry-version: ${{ matrix.poetry-version }}
|
poetry-version: ${{ matrix.poetry-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
|
||||||
VIRTUAL_ENV: ./venv
|
|
||||||
PATH: ${{ env.VIRTUAL_ENV }}/bin:${{ env.PATH }}
|
|
||||||
run: |
|
run: |
|
||||||
poetry install
|
poetry install
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
ENV PATH="/root/.local/bin:$PATH"
|
ENV PATH="/root/.local/bin:$PATH"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN mkdir -p lnbits/data
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN poetry config virtualenvs.create false
|
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
|
RUN poetry run python build.py
|
||||||
|
|
||||||
ENV LNBITS_PORT="5000"
|
ENV LNBITS_PORT="5000"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
title: "LNbits docs"
|
title: "LNbits docs"
|
||||||
|
|
||||||
remote_theme: pmarsceill/just-the-docs
|
remote_theme: pmarsceill/just-the-docs
|
||||||
logo: "/logos/lnbits-full.png"
|
color_scheme: dark
|
||||||
|
logo: "/logos/lnbits-full--inverse.png"
|
||||||
search_enabled: true
|
search_enabled: true
|
||||||
url: https://legend.lnbits.org
|
url: https://legend.lnbits.org
|
||||||
aux_links:
|
aux_links:
|
||||||
|
|
|
||||||
|
|
@ -9,53 +9,10 @@ nav_order: 2
|
||||||
Websockets
|
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:
|
Example vue-js function for listening to the websocket:
|
||||||
|
|
||||||
|
|
@ -67,16 +24,16 @@ initWs: async function () {
|
||||||
document.domain +
|
document.domain +
|
||||||
':' +
|
':' +
|
||||||
location.port +
|
location.port +
|
||||||
'/extension/ws/' +
|
'/api/v1/ws/' +
|
||||||
self.extension.id
|
self.item.id
|
||||||
} else {
|
} else {
|
||||||
localUrl =
|
localUrl =
|
||||||
'ws://' +
|
'ws://' +
|
||||||
document.domain +
|
document.domain +
|
||||||
':' +
|
':' +
|
||||||
location.port +
|
location.port +
|
||||||
'/extension/ws/' +
|
'/api/v1/ws/' +
|
||||||
self.extension.id
|
self.item.id
|
||||||
}
|
}
|
||||||
this.ws = new WebSocket(localUrl)
|
this.ws = new WebSocket(localUrl)
|
||||||
this.ws.addEventListener('message', async ({data}) => {
|
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
|
# 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.
|
# 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
|
## Option 2: Nix
|
||||||
|
|
||||||
|
|
@ -75,8 +84,8 @@ LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/lnbits/lnbits-legend.git
|
git clone https://github.com/lnbits/lnbits-legend.git
|
||||||
cd lnbits-legend/
|
cd lnbits-legend/
|
||||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
|
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3.9-venv'
|
||||||
python3 -m venv venv
|
python3.9 -m venv venv
|
||||||
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
||||||
./venv/bin/pip install -r requirements.txt
|
./venv/bin/pip install -r requirements.txt
|
||||||
# create the data folder and the .env file
|
# 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
|
## 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).
|
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
|
fly volumes create lnbits_data --size 1
|
||||||
|
|
@ -220,8 +229,8 @@ You need to edit the `.env` file.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||||
# postgres://<user>:<myPassword>@<host>/<lnbits> - alter line bellow with your user, password and db name
|
# postgres://<user>:<myPassword>@<host>:<port>/<lnbits> - alter line bellow with your user, password and db name
|
||||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/lnbits"
|
||||||
# save and exit
|
# save and exit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
[[source]]
|
|
||||||
url = "https://pypi.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
name = "pypi"
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
aiofiles = "==0.8.0"
|
|
||||||
anyio = "==3.6.1"
|
|
||||||
asyncio = "==3.4.3"
|
|
||||||
attrs = "==21.4.0"
|
|
||||||
bech32 = "==1.2.0"
|
|
||||||
bitstring = "==3.1.9"
|
|
||||||
cerberus = "==1.3.4"
|
|
||||||
certifi = "==2022.6.15"
|
|
||||||
cffi = "==1.15.0"
|
|
||||||
click = "==8.1.3"
|
|
||||||
ecdsa = "==0.18.0"
|
|
||||||
embit = "==0.5.0"
|
|
||||||
environs = "==9.5.0"
|
|
||||||
fastapi = "==0.79.0"
|
|
||||||
h11 = "==0.12.0"
|
|
||||||
httpcore = "==0.15.0"
|
|
||||||
httptools = "==0.4.0"
|
|
||||||
httpx = "==0.23.0"
|
|
||||||
idna = "==3.3"
|
|
||||||
jinja2 = "==3.0.1"
|
|
||||||
lnurl = "==0.3.6"
|
|
||||||
loguru = "==0.6.0"
|
|
||||||
markupsafe = "==2.1.1"
|
|
||||||
marshmallow = "==3.17.0"
|
|
||||||
outcome = "==1.2.0"
|
|
||||||
psycopg2-binary = "==2.9.3"
|
|
||||||
pycparser = "==2.21"
|
|
||||||
pycryptodomex = "==3.15.0"
|
|
||||||
pydantic = "==1.9.1"
|
|
||||||
pyngrok = "==5.1.0"
|
|
||||||
pyparsing = "==3.0.9"
|
|
||||||
pypng = "==0.20220715.0"
|
|
||||||
pyqrcode = "==1.2.1"
|
|
||||||
pyscss = "==1.4.0"
|
|
||||||
python-dotenv = "==0.20.0"
|
|
||||||
pyyaml = "==6.0"
|
|
||||||
represent = "==1.6.0.post0"
|
|
||||||
rfc3986 = "==1.5.0"
|
|
||||||
secp256k1 = "==0.14.0"
|
|
||||||
shortuuid = "==1.0.9"
|
|
||||||
six = "==1.16.0"
|
|
||||||
sniffio = "==1.2.0"
|
|
||||||
sqlalchemy-aio = "==0.17.0"
|
|
||||||
sqlalchemy = "==1.3.23"
|
|
||||||
sse-starlette = "==0.10.3"
|
|
||||||
starlette = "==0.19.1"
|
|
||||||
typing-extensions = "==4.3.0"
|
|
||||||
uvicorn = "==0.18.2"
|
|
||||||
uvloop = "==0.16.0"
|
|
||||||
watchfiles = "==0.16.0"
|
|
||||||
websockets = "==10.3"
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
|
|
||||||
[requires]
|
|
||||||
python_version = "3.9"
|
|
||||||
|
|
@ -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
|
from .app import create_app
|
||||||
|
|
||||||
app = 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}")
|
|
||||||
|
|
|
||||||
172
lnbits/app.py
172
lnbits/app.py
|
|
@ -4,21 +4,22 @@ import logging
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
import warnings
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
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.cors import CORSMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
import lnbits.settings
|
|
||||||
from lnbits.core.tasks import register_task_listeners
|
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 import core_app
|
||||||
|
from .core.services import check_admin_settings
|
||||||
from .core.views.generic import core_html_routes
|
from .core.views.generic import core_html_routes
|
||||||
from .helpers import (
|
from .helpers import (
|
||||||
get_css_vendored,
|
get_css_vendored,
|
||||||
|
|
@ -28,7 +29,6 @@ from .helpers import (
|
||||||
url_for_vendored,
|
url_for_vendored,
|
||||||
)
|
)
|
||||||
from .requestvars import g
|
from .requestvars import g
|
||||||
from .settings import WALLET
|
|
||||||
from .tasks import (
|
from .tasks import (
|
||||||
catch_everything_and_restart,
|
catch_everything_and_restart,
|
||||||
check_pending_payments,
|
check_pending_payments,
|
||||||
|
|
@ -38,10 +38,8 @@ from .tasks import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_object="lnbits.settings") -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create application factory.
|
|
||||||
:param config_object: The configuration object to use.
|
|
||||||
"""
|
|
||||||
configure_logger()
|
configure_logger()
|
||||||
|
|
||||||
app = FastAPI(
|
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.",
|
description="API for LNbits, the free and open source bitcoin wallet and accounts system with plugins.",
|
||||||
license_info={
|
license_info={
|
||||||
"name": "MIT License",
|
"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("/static", StaticFiles(packages=[("lnbits", "static")]), name="static")
|
||||||
app.mount(
|
app.mount(
|
||||||
"/core/static",
|
"/core/static",
|
||||||
|
|
@ -59,40 +58,15 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
name="core_static",
|
name="core_static",
|
||||||
)
|
)
|
||||||
|
|
||||||
origins = ["*"]
|
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||||
|
|
||||||
app.add_middleware(
|
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)
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||||
|
|
||||||
check_funding_source(app)
|
register_startup(app)
|
||||||
register_assets(app)
|
register_assets(app)
|
||||||
register_routes(app)
|
register_routes(app)
|
||||||
register_async_tasks(app)
|
register_async_tasks(app)
|
||||||
|
|
@ -101,9 +75,8 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def check_funding_source(app: FastAPI) -> None:
|
async def check_funding_source() -> None:
|
||||||
@app.on_event("startup")
|
|
||||||
async def check_wallet_status():
|
|
||||||
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
original_sigint_handler = signal.getsignal(signal.SIGINT)
|
||||||
|
|
||||||
def signal_handler(signal, frame):
|
def signal_handler(signal, frame):
|
||||||
|
|
@ -111,6 +84,8 @@ def check_funding_source(app: FastAPI) -> None:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
WALLET = get_wallet_class()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
error_message, balance = await WALLET.status()
|
error_message, balance = await WALLET.status()
|
||||||
|
|
@ -125,7 +100,7 @@ def check_funding_source(app: FastAPI) -> None:
|
||||||
logger.info("Retrying connection to backend in 5 seconds...")
|
logger.info("Retrying connection to backend in 5 seconds...")
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||||
logger.success(
|
logger.info(
|
||||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -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):
|
def register_assets(app: FastAPI):
|
||||||
"""Serve each vendored asset separately or a bundle."""
|
"""Serve each vendored asset separately or a bundle."""
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def vendored_assets_variable():
|
async def vendored_assets_variable():
|
||||||
if g().config.DEBUG:
|
if settings.debug:
|
||||||
g().VENDORED_JS = map(url_for_vendored, get_js_vendored())
|
g().VENDORED_JS = map(url_for_vendored, get_js_vendored())
|
||||||
g().VENDORED_CSS = map(url_for_vendored, get_css_vendored())
|
g().VENDORED_CSS = map(url_for_vendored, get_css_vendored())
|
||||||
else:
|
else:
|
||||||
|
|
@ -192,12 +214,33 @@ def register_async_tasks(app):
|
||||||
|
|
||||||
def register_exception_handlers(app: FastAPI):
|
def register_exception_handlers(app: FastAPI):
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def basic_error(request: Request, err):
|
async def exception_handler(request: Request, exc: Exception):
|
||||||
logger.error("handled error", traceback.format_exc())
|
|
||||||
logger.error("ERROR:", err)
|
|
||||||
etype, _, tb = sys.exc_info()
|
etype, _, tb = sys.exc_info()
|
||||||
traceback.print_exception(etype, err, tb)
|
traceback.print_exception(etype, exc, tb)
|
||||||
exc = traceback.format_exc()
|
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 (
|
if (
|
||||||
request.headers
|
request.headers
|
||||||
|
|
@ -205,18 +248,43 @@ def register_exception_handlers(app: FastAPI):
|
||||||
and "text/html" in request.headers["accept"]
|
and "text/html" in request.headers["accept"]
|
||||||
):
|
):
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html", {"request": request, "err": err}
|
"error.html",
|
||||||
|
{"request": request, "err": f"Error: {str(exc)}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=HTTPStatus.NO_CONTENT,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
content={"detail": err},
|
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:
|
def configure_logger() -> None:
|
||||||
logger.remove()
|
logger.remove()
|
||||||
log_level: str = "DEBUG" if lnbits.settings.DEBUG else "INFO"
|
log_level: str = "DEBUG" if settings.debug else "INFO"
|
||||||
formatter = Formatter()
|
formatter = Formatter()
|
||||||
logger.add(sys.stderr, level=log_level, format=formatter.format)
|
logger.add(sys.stderr, level=log_level, format=formatter.format)
|
||||||
|
|
||||||
|
|
@ -228,7 +296,7 @@ class Formatter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.padding = 0
|
self.padding = 0
|
||||||
self.minimal_fmt: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SS}</green> | <level>{level}</level> | <level>{message}</level>\n"
|
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"
|
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:
|
else:
|
||||||
self.fmt: str = self.minimal_fmt
|
self.fmt: str = self.minimal_fmt
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import warnings
|
||||||
import click
|
import click
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from .core import db as core_db
|
from .core import db as core_db
|
||||||
from .core import migrations as core_migrations
|
from .core import migrations as core_migrations
|
||||||
from .db import COCKROACH, POSTGRES, SQLITE
|
from .db import COCKROACH, POSTGRES, SQLITE
|
||||||
|
|
@ -16,7 +18,6 @@ from .helpers import (
|
||||||
get_valid_extensions,
|
get_valid_extensions,
|
||||||
url_for_vendored,
|
url_for_vendored,
|
||||||
)
|
)
|
||||||
from .settings import LNBITS_PATH
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("migrate")
|
@click.command("migrate")
|
||||||
|
|
@ -35,15 +36,17 @@ def transpile_scss():
|
||||||
warnings.simplefilter("ignore")
|
warnings.simplefilter("ignore")
|
||||||
from scss.compiler import compile_string # type: 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(settings.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/css/base.css"), "w"
|
||||||
|
) as css:
|
||||||
css.write(compile_string(scss.read()))
|
css.write(compile_string(scss.read()))
|
||||||
|
|
||||||
|
|
||||||
def bundle_vendored():
|
def bundle_vendored():
|
||||||
for getfiles, outputpath in [
|
for getfiles, outputpath in [
|
||||||
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
|
(get_js_vendored, os.path.join(settings.lnbits_path, "static/bundle.js")),
|
||||||
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
|
(get_css_vendored, os.path.join(settings.lnbits_path, "static/bundle.css")),
|
||||||
]:
|
]:
|
||||||
output = ""
|
output = ""
|
||||||
for path in getfiles():
|
for path in getfiles():
|
||||||
|
|
@ -65,8 +68,7 @@ async def migrate_databases():
|
||||||
(db_name, version, version),
|
(db_name, version, version),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def run_migration(db, migrations_module):
|
async def run_migration(db, migrations_module, db_name):
|
||||||
db_name = migrations_module.__name__.split(".")[-2]
|
|
||||||
for key, migrate in migrations_module.__dict__.items():
|
for key, migrate in migrations_module.__dict__.items():
|
||||||
match = match = matcher.match(key)
|
match = match = matcher.match(key)
|
||||||
if match:
|
if match:
|
||||||
|
|
@ -97,20 +99,24 @@ async def migrate_databases():
|
||||||
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
|
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
|
||||||
current_versions = {row["db"]: row["version"] for row in rows}
|
current_versions = {row["db"]: row["version"] for row in rows}
|
||||||
matcher = re.compile(r"^m(\d\d\d)_")
|
matcher = re.compile(r"^m(\d\d\d)_")
|
||||||
await run_migration(conn, core_migrations)
|
db_name = core_migrations.__name__.split(".")[-2]
|
||||||
|
await run_migration(conn, core_migrations, db_name)
|
||||||
|
|
||||||
for ext in get_valid_extensions():
|
for ext in get_valid_extensions():
|
||||||
try:
|
try:
|
||||||
ext_migrations = importlib.import_module(
|
|
||||||
f"lnbits.extensions.{ext.code}.migrations"
|
module_str = (
|
||||||
|
ext.migration_module or f"lnbits.extensions.{ext.code}.migrations"
|
||||||
)
|
)
|
||||||
|
ext_migrations = importlib.import_module(module_str)
|
||||||
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
|
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
|
||||||
|
db_name = ext.db_name or module_str.split(".")[-2]
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
||||||
)
|
)
|
||||||
|
|
||||||
async with ext_db.connect() as ext_conn:
|
async with ext_db.connect() as ext_conn:
|
||||||
await run_migration(ext_conn, ext_migrations)
|
await run_migration(ext_conn, ext_migrations, db_name)
|
||||||
|
|
||||||
logger.info("✔️ All migrations done.")
|
logger.info("✔️ All migrations done.")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ db = Database("database")
|
||||||
|
|
||||||
core_app: APIRouter = APIRouter()
|
core_app: APIRouter = APIRouter()
|
||||||
|
|
||||||
|
from .views.admin_api import * # noqa
|
||||||
from .views.api import * # noqa
|
from .views.api import * # noqa
|
||||||
from .views.generic import * # noqa
|
from .views.generic import * # noqa
|
||||||
from .views.public_api 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 urllib.parse import urlparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.db import COCKROACH, POSTGRES, Connection
|
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 . import db
|
||||||
from .models import BalanceCheck, Payment, User, Wallet
|
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"],
|
email=user["email"],
|
||||||
extensions=[e[0] for e in extensions],
|
extensions=[e[0] for e in extensions],
|
||||||
wallets=[Wallet(**w) for w in wallets],
|
wallets=[Wallet(**w) for w in wallets],
|
||||||
admin=user["id"] in [x.strip() for x in LNBITS_ADMIN_USERS]
|
admin=user["id"] == settings.super_user
|
||||||
if LNBITS_ADMIN_USERS
|
or user["id"] in settings.lnbits_admin_users,
|
||||||
else False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -99,7 +96,7 @@ async def create_wallet(
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
wallet_id,
|
wallet_id,
|
||||||
wallet_name or DEFAULT_WALLET_NAME,
|
wallet_name or settings.lnbits_default_wallet_name,
|
||||||
user_id,
|
user_id,
|
||||||
uuid4().hex,
|
uuid4().hex,
|
||||||
uuid4().hex,
|
uuid4().hex,
|
||||||
|
|
@ -229,6 +226,24 @@ async def get_wallet_payment(
|
||||||
return Payment.from_row(row) if row else None
|
return Payment.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
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'
|
||||||
|
AND extra LIKE ?
|
||||||
|
AND extra LIKE ?
|
||||||
|
ORDER BY time DESC LIMIT {limit}
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"%{ext_name}%",
|
||||||
|
f"%{ext_id}%",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
async def get_payments(
|
async def get_payments(
|
||||||
*,
|
*,
|
||||||
wallet_id: Optional[str] = None,
|
wallet_id: Optional[str] = None,
|
||||||
|
|
@ -321,36 +336,13 @@ async def delete_expired_invoices(
|
||||||
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
|
AND time < {db.timestamp_now} - {db.interval_seconds(2592000)}
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# then we delete all invoices whose expiry date is in the past
|
||||||
# then we delete all expired invoices, checking one by one
|
|
||||||
rows = await (conn or db).fetchall(
|
|
||||||
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)}
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
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(
|
await (conn or db).execute(
|
||||||
"""
|
f"""
|
||||||
DELETE FROM apipayments
|
DELETE FROM apipayments
|
||||||
WHERE pending = true AND hash = ?
|
WHERE pending = true AND amount > 0
|
||||||
""",
|
AND expiry < {db.timestamp_now}
|
||||||
(invoice.payment_hash,),
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -378,12 +370,19 @@ async def create_payment(
|
||||||
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||||
# assert previous_payment is None, "Payment already exists"
|
# 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(
|
await (conn or db).execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO apipayments
|
INSERT INTO apipayments
|
||||||
(wallet, checking_id, bolt11, hash, preimage,
|
(wallet, checking_id, bolt11, hash, preimage,
|
||||||
amount, pending, memo, fee, extra, webhook)
|
amount, pending, memo, fee, extra, webhook, expiry)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
wallet_id,
|
wallet_id,
|
||||||
|
|
@ -399,6 +398,7 @@ async def create_payment(
|
||||||
if extra and extra != {} and type(extra) is dict
|
if extra and extra != {} and type(extra) is dict
|
||||||
else None,
|
else None,
|
||||||
webhook,
|
webhook,
|
||||||
|
db.datetime_to_timestamp(expiration_date),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -547,3 +547,48 @@ async def get_balance_notify(
|
||||||
(wallet_id,),
|
(wallet_id,),
|
||||||
)
|
)
|
||||||
return row[0] if row else None
|
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 sqlalchemy.exc import OperationalError # type: ignore
|
||||||
|
|
||||||
|
from lnbits import bolt11
|
||||||
|
|
||||||
|
|
||||||
async def m000_create_migrations_table(db):
|
async def m000_create_migrations_table(db):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|
@ -51,7 +56,7 @@ async def m001_initial(db):
|
||||||
f"""
|
f"""
|
||||||
CREATE TABLE IF NOT EXISTS apipayments (
|
CREATE TABLE IF NOT EXISTS apipayments (
|
||||||
payhash TEXT NOT NULL,
|
payhash TEXT NOT NULL,
|
||||||
amount INTEGER NOT NULL,
|
amount {db.big_int} NOT NULL,
|
||||||
fee INTEGER NOT NULL DEFAULT 0,
|
fee INTEGER NOT NULL DEFAULT 0,
|
||||||
wallet TEXT NOT NULL,
|
wallet TEXT NOT NULL,
|
||||||
pending BOOLEAN NOT NULL,
|
pending BOOLEAN NOT NULL,
|
||||||
|
|
@ -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 hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
from sqlite3 import Row
|
from sqlite3 import Row
|
||||||
from typing import Dict, List, NamedTuple, Optional
|
from typing import Dict, List, NamedTuple, Optional
|
||||||
|
|
||||||
from ecdsa import SECP256k1, SigningKey # type: ignore
|
from ecdsa import SECP256k1, SigningKey # type: ignore
|
||||||
|
from fastapi import Query
|
||||||
from lnurl import encode as lnurl_encode # type: ignore
|
from lnurl import encode as lnurl_encode # type: ignore
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Extra, validator
|
||||||
|
|
||||||
from lnbits.db import Connection
|
from lnbits.db import Connection
|
||||||
from lnbits.helpers import url_for
|
from lnbits.helpers import url_for
|
||||||
from lnbits.settings import WALLET
|
from lnbits.settings import get_wallet_class
|
||||||
from lnbits.wallets.base import PaymentStatus
|
from lnbits.wallets.base import PaymentStatus
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -63,6 +66,7 @@ class User(BaseModel):
|
||||||
wallets: List[Wallet] = []
|
wallets: List[Wallet] = []
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
admin: bool = False
|
admin: bool = False
|
||||||
|
super_user: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def wallet_ids(self) -> List[str]:
|
def wallet_ids(self) -> List[str]:
|
||||||
|
|
@ -83,6 +87,7 @@ class Payment(BaseModel):
|
||||||
bolt11: str
|
bolt11: str
|
||||||
preimage: str
|
preimage: str
|
||||||
payment_hash: str
|
payment_hash: str
|
||||||
|
expiry: Optional[float]
|
||||||
extra: Optional[Dict] = {}
|
extra: Optional[Dict] = {}
|
||||||
wallet_id: str
|
wallet_id: str
|
||||||
webhook: Optional[str]
|
webhook: Optional[str]
|
||||||
|
|
@ -101,6 +106,7 @@ class Payment(BaseModel):
|
||||||
fee=row["fee"],
|
fee=row["fee"],
|
||||||
memo=row["memo"],
|
memo=row["memo"],
|
||||||
time=row["time"],
|
time=row["time"],
|
||||||
|
expiry=row["expiry"],
|
||||||
wallet_id=row["wallet"],
|
wallet_id=row["wallet"],
|
||||||
webhook=row["webhook"],
|
webhook=row["webhook"],
|
||||||
webhook_status=row["webhook_status"],
|
webhook_status=row["webhook_status"],
|
||||||
|
|
@ -128,6 +134,10 @@ class Payment(BaseModel):
|
||||||
def is_out(self) -> bool:
|
def is_out(self) -> bool:
|
||||||
return self.amount < 0
|
return self.amount < 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
return self.expiry < time.time() if self.expiry else False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_uncheckable(self) -> bool:
|
def is_uncheckable(self) -> bool:
|
||||||
return self.checking_id.startswith("internal_")
|
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}"
|
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
WALLET = get_wallet_class()
|
||||||
if self.is_out:
|
if self.is_out:
|
||||||
status = await WALLET.get_payment_status(self.checking_id)
|
status = await WALLET.get_payment_status(self.checking_id)
|
||||||
else:
|
else:
|
||||||
|
|
@ -170,7 +181,13 @@ class Payment(BaseModel):
|
||||||
|
|
||||||
logger.debug(f"Status: {status}")
|
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(
|
logger.warning(
|
||||||
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
f"Deleting outgoing failed payment {self.checking_id}: {status}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
from binascii import unhexlify
|
from binascii import unhexlify
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Dict, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends
|
from fastapi import Depends, WebSocket
|
||||||
from lnurl import LnurlErrorResponse
|
from lnurl import LnurlErrorResponse
|
||||||
from lnurl import decode as decode_lnurl # type: ignore
|
from lnurl import decode as decode_lnurl # type: ignore
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
@ -21,18 +21,31 @@ from lnbits.decorators import (
|
||||||
)
|
)
|
||||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||||
from lnbits.requestvars import g
|
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 lnbits.wallets.base import PaymentResponse, PaymentStatus
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .crud import (
|
from .crud import (
|
||||||
check_internal,
|
check_internal,
|
||||||
|
create_account,
|
||||||
|
create_admin_settings,
|
||||||
create_payment,
|
create_payment,
|
||||||
|
create_wallet,
|
||||||
delete_wallet_payment,
|
delete_wallet_payment,
|
||||||
|
get_account,
|
||||||
|
get_super_settings,
|
||||||
get_wallet,
|
get_wallet,
|
||||||
get_wallet_payment,
|
get_wallet_payment,
|
||||||
update_payment_details,
|
update_payment_details,
|
||||||
update_payment_status,
|
update_payment_status,
|
||||||
|
update_super_user,
|
||||||
)
|
)
|
||||||
from .models import Payment
|
from .models import Payment
|
||||||
|
|
||||||
|
|
@ -65,7 +78,7 @@ async def create_invoice(
|
||||||
invoice_memo = None if description_hash else memo
|
invoice_memo = None if description_hash else memo
|
||||||
|
|
||||||
# use the fake wallet if the invoice is for internal use only
|
# 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(
|
ok, checking_id, payment_request, error_message = await wallet.create_invoice(
|
||||||
amount=amount,
|
amount=amount,
|
||||||
|
|
@ -193,6 +206,7 @@ async def pay_invoice(
|
||||||
else:
|
else:
|
||||||
logger.debug(f"backend: sending payment {temp_id}")
|
logger.debug(f"backend: sending payment {temp_id}")
|
||||||
# actually pay the external invoice
|
# actually pay the external invoice
|
||||||
|
WALLET = get_wallet_class()
|
||||||
payment: PaymentResponse = await WALLET.pay_invoice(
|
payment: PaymentResponse = await WALLET.pay_invoice(
|
||||||
payment_request, fee_reserve_msat
|
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
|
# 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:
|
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
|
this.parse.camera.show = false
|
||||||
},
|
},
|
||||||
updateBalance: function (credit) {
|
updateBalance: function (credit) {
|
||||||
if (LNBITS_DENOMINATION != 'sats') {
|
|
||||||
credit = credit * 100
|
|
||||||
}
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request('PUT', '/api/v1/wallet/balance/' + credit, this.g.wallet.inkey)
|
.request(
|
||||||
.catch(err => {
|
'PUT',
|
||||||
LNbits.utils.notifyApiError(err)
|
'/admin/api/v1/topup/?usr=' + this.g.user.id,
|
||||||
})
|
this.g.user.wallets[0].adminkey,
|
||||||
.then(response => {
|
{
|
||||||
let data = response.data
|
amount: credit,
|
||||||
if (data.status === 'ERROR') {
|
id: this.g.user.wallets[0].id
|
||||||
this.$q.notify({
|
|
||||||
timeout: 5000,
|
|
||||||
type: 'warning',
|
|
||||||
message: `Failed to update.`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
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 () {
|
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>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>{{SITE_DESCRIPTION}}</p>
|
<p v-else>{{SITE_DESCRIPTION | safe}}</p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -183,6 +183,23 @@
|
||||||
<div class="col q-pl-md"> </div>
|
<div class="col q-pl-md"> </div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||||
|
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||||
|
<q-btn flat color="secondary" class="full-width q-mb-md"
|
||||||
|
>{{ AD_TITLE }}</q-btn
|
||||||
|
>
|
||||||
|
|
||||||
|
<a href="{{ AD[0] }}" class="q-ma-md">
|
||||||
|
<img
|
||||||
|
v-if="($q.dark.isActive)"
|
||||||
|
src="{{ AD[1] }}"
|
||||||
|
style="max-width: 90%"
|
||||||
|
/>
|
||||||
|
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %} {% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@
|
||||||
<!---->
|
<!---->
|
||||||
{% block page %}
|
{% block page %}
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
|
{% if HIDE_API and AD_SPACE %}
|
||||||
|
<div class="col-12 col-md-8 q-gutter-y-md">
|
||||||
|
{% elif HIDE_API %}
|
||||||
|
<div class="col-12 q-gutter-y-md">
|
||||||
|
{% else %}
|
||||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||||
|
{% endif %}
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h3 class="q-my-none">
|
<h3 class="q-my-none">
|
||||||
|
|
@ -100,7 +106,9 @@
|
||||||
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
|
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
<q-btn flat color="grey" @click="exportCSV"
|
||||||
|
>Export to CSV</q-btn
|
||||||
|
>
|
||||||
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
|
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
|
||||||
<q-tooltip>Check pending</q-tooltip>
|
<q-tooltip>Check pending</q-tooltip>
|
||||||
</q-btn>-->
|
</q-btn>-->
|
||||||
|
|
@ -170,7 +178,11 @@
|
||||||
:props="props"
|
:props="props"
|
||||||
style="white-space: normal; word-break: break-all"
|
style="white-space: normal; word-break: break-all"
|
||||||
>
|
>
|
||||||
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
|
<q-badge
|
||||||
|
v-if="props.row.tag"
|
||||||
|
color="yellow"
|
||||||
|
text-color="black"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
class="inherit"
|
class="inherit"
|
||||||
:href="['/', props.row.tag, '/?usr=', user.id].join('')"
|
:href="['/', props.row.tag, '/?usr=', user.id].join('')"
|
||||||
|
|
@ -190,8 +202,9 @@
|
||||||
key="sat"
|
key="sat"
|
||||||
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
|
||||||
:props="props"
|
:props="props"
|
||||||
>{% raw %} {{ parseFloat(String(props.row.fsat).replaceAll(",",
|
>{% raw %} {{
|
||||||
"")) / 100 }}
|
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100
|
||||||
|
}}
|
||||||
</q-td>
|
</q-td>
|
||||||
|
|
||||||
<q-td auto-width key="sat" v-else :props="props">
|
<q-td auto-width key="sat" v-else :props="props">
|
||||||
|
|
@ -211,7 +224,10 @@
|
||||||
<lnbits-payment-details
|
<lnbits-payment-details
|
||||||
:payment="props.row"
|
:payment="props.row"
|
||||||
></lnbits-payment-details>
|
></lnbits-payment-details>
|
||||||
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
|
<div
|
||||||
|
v-if="props.row.bolt11"
|
||||||
|
class="text-center q-mb-lg"
|
||||||
|
>
|
||||||
<a :href="'lightning:' + props.row.bolt11">
|
<a :href="'lightning:' + props.row.bolt11">
|
||||||
<q-responsive :ratio="1" class="q-mx-xl">
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
<qrcode
|
<qrcode
|
||||||
|
|
@ -229,7 +245,11 @@
|
||||||
@click="copyText(props.row.bolt11)"
|
@click="copyText(props.row.bolt11)"
|
||||||
>Copy invoice</q-btn
|
>Copy invoice</q-btn
|
||||||
>
|
>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
color="grey"
|
||||||
|
class="q-ml-auto"
|
||||||
>Close</q-btn
|
>Close</q-btn
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -280,7 +300,8 @@
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||||
{{ SITE_TITLE }} Wallet: <strong><em>{{ wallet.name }}</em></strong>
|
{{ SITE_TITLE }} Wallet:
|
||||||
|
<strong><em>{{ wallet.name }}</em></strong>
|
||||||
</h6>
|
</h6>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
|
|
@ -299,8 +320,8 @@
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center">
|
||||||
<p>
|
<p>
|
||||||
This is an LNURL-withdraw QR code for slurping everything
|
This is an LNURL-withdraw QR code for slurping
|
||||||
from this wallet. Do not share with anyone.
|
everything from this wallet. Do not share with anyone.
|
||||||
</p>
|
</p>
|
||||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||||
<qrcode
|
<qrcode
|
||||||
|
|
@ -310,8 +331,9 @@
|
||||||
</a>
|
</a>
|
||||||
<p>
|
<p>
|
||||||
It is compatible with <code>balanceCheck</code> and
|
It is compatible with <code>balanceCheck</code> and
|
||||||
<code>balanceNotify</code> so your wallet may keep pulling
|
<code>balanceNotify</code> so your wallet may keep
|
||||||
the funds continuously from here after the first withdraw.
|
pulling the funds continuously from here after the first
|
||||||
|
withdraw.
|
||||||
</p>
|
</p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
@ -327,8 +349,9 @@
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section class="text-center">
|
<q-card-section class="text-center">
|
||||||
<p>
|
<p>
|
||||||
This QR code contains your wallet URL with full access. You
|
This QR code contains your wallet URL with full access.
|
||||||
can scan it from your phone to open your wallet from there.
|
You can scan it from your phone to open your wallet from
|
||||||
|
there.
|
||||||
</p>
|
</p>
|
||||||
<qrcode
|
<qrcode
|
||||||
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
:value="'{{request.base_url}}' +'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||||
|
|
@ -338,7 +361,11 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-expansion-item>
|
</q-expansion-item>
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
<q-expansion-item group="extras" icon="edit" label="Rename wallet">
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="edit"
|
||||||
|
label="Rename wallet"
|
||||||
|
>
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="" style="max-width: 320px">
|
<div class="" style="max-width: 320px">
|
||||||
|
|
@ -386,11 +413,30 @@
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
|
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
|
||||||
ADS.split(';') %}
|
ADS.split(";") %}
|
||||||
<q-card>
|
<q-card>
|
||||||
<a href="{{ AD[0] }}"
|
<q-card-section>
|
||||||
><img width="100%" src="{{ AD[1] }}"
|
<h6 class="text-subtitle1 q-mt-none q-mb-sm">
|
||||||
/></a> </q-card
|
{{ AD_SPACE_TITLE }}
|
||||||
|
</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<a
|
||||||
|
style="display: inline-block"
|
||||||
|
href="{{ AD[0] }}"
|
||||||
|
class="q-ma-md"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
style="max-width: 100%; height: auto"
|
||||||
|
v-if="($q.dark.isActive)"
|
||||||
|
src="{{ AD[1] }}"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
style="max-width: 100%; height: auto"
|
||||||
|
v-else
|
||||||
|
src="{{ AD[2] }}"
|
||||||
|
/>
|
||||||
|
</a> </q-card-section></q-card
|
||||||
>{% endfor %} {% endif %}
|
>{% endfor %} {% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -489,7 +535,9 @@
|
||||||
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
|
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
|
||||||
>Copy invoice</q-btn
|
>Copy invoice</q-btn
|
||||||
>
|
>
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Close</q-btn
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</q-card>
|
</q-card>
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
|
|
@ -503,7 +551,8 @@
|
||||||
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||||
</h6>
|
</h6>
|
||||||
<h6 v-else class="q-my-none">
|
<h6 v-else class="q-my-none">
|
||||||
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {%
|
||||||
|
raw %}
|
||||||
</h6>
|
</h6>
|
||||||
<q-separator class="q-my-sm"></q-separator>
|
<q-separator class="q-my-sm"></q-separator>
|
||||||
<p class="text-wrap">
|
<p class="text-wrap">
|
||||||
|
|
@ -535,10 +584,10 @@
|
||||||
</p>
|
</p>
|
||||||
<q-separator class="q-my-sm"></q-separator>
|
<q-separator class="q-my-sm"></q-separator>
|
||||||
<p>
|
<p>
|
||||||
For every website and for every LNbits wallet, a new keypair will be
|
For every website and for every LNbits wallet, a new keypair
|
||||||
deterministically generated so your identity can't be tied to your
|
will be deterministically generated so your identity can't be
|
||||||
LNbits wallet or linked across websites. No other data will be
|
tied to your LNbits wallet or linked across websites. No other
|
||||||
shared with {{ parse.lnurlauth.domain }}.
|
data will be shared with {{ parse.lnurlauth.domain }}.
|
||||||
</p>
|
</p>
|
||||||
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||||
<p class="q-mx-xl">
|
<p class="q-mx-xl">
|
||||||
|
|
@ -566,9 +615,10 @@
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="q-my-none text-h6 text-center">
|
<p v-else class="q-my-none text-h6 text-center">
|
||||||
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> is
|
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b>
|
||||||
requesting <br />
|
is requesting <br />
|
||||||
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
|
between
|
||||||
|
<b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
|
||||||
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
|
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b>
|
||||||
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||||
|
|
@ -600,7 +650,10 @@
|
||||||
></q-input>
|
></q-input>
|
||||||
{% raw %}
|
{% raw %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
|
<div
|
||||||
|
class="col-8 q-pl-md"
|
||||||
|
v-if="parse.lnurlpay.commentAllowed > 0"
|
||||||
|
>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -702,7 +755,8 @@
|
||||||
@click="g.visibleDrawer = !g.visibleDrawer"
|
@click="g.visibleDrawer = !g.visibleDrawer"
|
||||||
>
|
>
|
||||||
</q-tab>
|
</q-tab>
|
||||||
<q-tab icon="content_paste" label="Paste" @click="showParseDialog"> </q-tab>
|
<q-tab icon="content_paste" label="Paste" @click="showParseDialog">
|
||||||
|
</q-tab>
|
||||||
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
|
<q-tab icon="file_download" label="Receive" @click="showReceiveDialog">
|
||||||
</q-tab>
|
</q-tab>
|
||||||
|
|
||||||
|
|
@ -720,11 +774,12 @@
|
||||||
>!
|
>!
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This service is in BETA, and we hold no responsibility for people losing
|
This service is in BETA, and we hold no responsibility for people
|
||||||
access to funds. {% if service_fee > 0 %} To encourage you to run your
|
losing access to funds. {% if service_fee > 0 %} To encourage you to
|
||||||
own LNbits installation, any balance on {% raw %}{{
|
run your own LNbits installation, any balance on {% raw %}{{
|
||||||
disclaimerDialog.location.host }}{% endraw %} will incur a charge of
|
disclaimerDialog.location.host }}{% endraw %} will incur a charge of
|
||||||
<strong>{{ service_fee }}% service fee</strong> per week. {% endif %}
|
<strong>{{ service_fee }}% service fee</strong> per week. {% endif
|
||||||
|
%}
|
||||||
</p>
|
</p>
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -740,4 +795,6 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
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"}
|
||||||
|
|
@ -12,25 +12,34 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||||
import async_timeout
|
import async_timeout
|
||||||
import httpx
|
import httpx
|
||||||
import pyqrcode
|
import pyqrcode
|
||||||
from fastapi import Depends, Header, Query, Request
|
from fastapi import (
|
||||||
|
Depends,
|
||||||
|
Header,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
WebSocket,
|
||||||
|
WebSocketDisconnect,
|
||||||
|
)
|
||||||
from fastapi.exceptions import HTTPException
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.params import Body
|
from fastapi.params import Body
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic.fields import Field
|
from pydantic.fields import Field
|
||||||
from sse_starlette.sse import EventSourceResponse, ServerSentEvent
|
from sse_starlette.sse import EventSourceResponse
|
||||||
from starlette.responses import HTMLResponse, StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from lnbits import bolt11, lnurl
|
from lnbits import bolt11, lnurl
|
||||||
from lnbits.core.models import Payment, Wallet
|
from lnbits.core.models import Payment, Wallet
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
WalletTypeInfo,
|
WalletTypeInfo,
|
||||||
|
check_admin,
|
||||||
get_key_type,
|
get_key_type,
|
||||||
require_admin_key,
|
require_admin_key,
|
||||||
require_invoice_key,
|
require_invoice_key,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
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 (
|
from lnbits.utils.exchange_rates import (
|
||||||
currencies,
|
currencies,
|
||||||
fiat_amount_as_satoshis,
|
fiat_amount_as_satoshis,
|
||||||
|
|
@ -56,6 +65,8 @@ from ..services import (
|
||||||
create_invoice,
|
create_invoice,
|
||||||
pay_invoice,
|
pay_invoice,
|
||||||
perform_lnurlauth,
|
perform_lnurlauth,
|
||||||
|
websocketManager,
|
||||||
|
websocketUpdater,
|
||||||
)
|
)
|
||||||
from ..tasks import api_invoice_listeners
|
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}
|
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}")
|
@core_app.put("/api/v1/wallet/{new_name}")
|
||||||
async def api_update_wallet(
|
async def api_update_wallet(
|
||||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
|
@ -155,30 +137,29 @@ class CreateInvoiceData(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||||
if data.description_hash:
|
if data.description_hash or data.unhashed_description:
|
||||||
try:
|
try:
|
||||||
description_hash = binascii.unhexlify(data.description_hash)
|
description_hash = (
|
||||||
|
binascii.unhexlify(data.description_hash)
|
||||||
|
if data.description_hash
|
||||||
|
else b""
|
||||||
|
)
|
||||||
|
unhashed_description = (
|
||||||
|
binascii.unhexlify(data.unhashed_description)
|
||||||
|
if data.unhashed_description
|
||||||
|
else b""
|
||||||
|
)
|
||||||
except binascii.Error:
|
except binascii.Error:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
detail="'description_hash' must be a valid hex string",
|
detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
|
||||||
)
|
)
|
||||||
unhashed_description = b""
|
|
||||||
memo = ""
|
|
||||||
elif data.unhashed_description:
|
|
||||||
try:
|
|
||||||
unhashed_description = binascii.unhexlify(data.unhashed_description)
|
|
||||||
except binascii.Error:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail="'unhashed_description' must be a valid hex string",
|
|
||||||
)
|
|
||||||
description_hash = b""
|
|
||||||
memo = ""
|
memo = ""
|
||||||
else:
|
else:
|
||||||
description_hash = b""
|
description_hash = b""
|
||||||
unhashed_description = b""
|
unhashed_description = b""
|
||||||
memo = data.memo or LNBITS_SITE_TITLE
|
memo = data.memo or settings.lnbits_site_title
|
||||||
|
|
||||||
if data.unit == "sat":
|
if data.unit == "sat":
|
||||||
amount = int(data.amount)
|
amount = int(data.amount)
|
||||||
else:
|
else:
|
||||||
|
|
@ -407,7 +388,7 @@ async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
|
||||||
|
|
||||||
yield dict(data=jdata, event=typ)
|
yield dict(data=jdata, event=typ)
|
||||||
except asyncio.CancelledError as e:
|
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)
|
api_invoice_listeners.pop(uid)
|
||||||
task.cancel()
|
task.cancel()
|
||||||
return
|
return
|
||||||
|
|
@ -585,8 +566,8 @@ class DecodePayment(BaseModel):
|
||||||
data: str
|
data: str
|
||||||
|
|
||||||
|
|
||||||
@core_app.post("/api/v1/payments/decode")
|
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||||
async def api_payments_decode(data: DecodePayment):
|
async def api_payments_decode(data: DecodePayment, response: Response):
|
||||||
payment_str = data.data
|
payment_str = data.data
|
||||||
try:
|
try:
|
||||||
if payment_str[:5] == "LNURL":
|
if payment_str[:5] == "LNURL":
|
||||||
|
|
@ -607,6 +588,7 @@ async def api_payments_decode(data: DecodePayment):
|
||||||
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
||||||
}
|
}
|
||||||
except:
|
except:
|
||||||
|
response.status_code = HTTPStatus.BAD_REQUEST
|
||||||
return {"message": "Failed to decode"}
|
return {"message": "Failed to decode"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -676,13 +658,9 @@ async def img(request: Request, data):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@core_app.get("/api/v1/audit/")
|
@core_app.get("/api/v1/audit/", dependencies=[Depends(check_admin)])
|
||||||
async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
|
async def api_auditor():
|
||||||
if wallet.wallet.user not in LNBITS_ADMIN_USERS:
|
WALLET = get_wallet_class()
|
||||||
raise HTTPException(
|
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="Not an admin user"
|
|
||||||
)
|
|
||||||
|
|
||||||
total_balance = await get_total_balance()
|
total_balance = await get_total_balance()
|
||||||
error_message, node_balance = await WALLET.status()
|
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
|
node_balance, delta = None, None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"node_balance_msats": node_balance,
|
"node_balance_msats": int(node_balance),
|
||||||
"lnbits_balance_msats": total_balance,
|
"lnbits_balance_msats": int(total_balance),
|
||||||
"delta_msats": delta,
|
"delta_msats": int(delta),
|
||||||
"timestamp": int(time.time()),
|
"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 import db
|
||||||
from lnbits.core.models import User
|
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.helpers import template_renderer, url_for
|
||||||
from lnbits.settings import (
|
from lnbits.settings import get_wallet_class, settings
|
||||||
LNBITS_ADMIN_USERS,
|
|
||||||
LNBITS_ALLOWED_USERS,
|
|
||||||
LNBITS_CUSTOM_LOGO,
|
|
||||||
LNBITS_SITE_TITLE,
|
|
||||||
SERVICE_FEE,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ...helpers import get_valid_extensions
|
from ...helpers import get_valid_extensions
|
||||||
from ..crud import (
|
from ..crud import (
|
||||||
|
|
@ -117,7 +111,6 @@ async def wallet(
|
||||||
user_id = usr.hex if usr else None
|
user_id = usr.hex if usr else None
|
||||||
wallet_id = wal.hex if wal else None
|
wallet_id = wal.hex if wal else None
|
||||||
wallet_name = nme
|
wallet_name = nme
|
||||||
service_fee = int(SERVICE_FEE) if int(SERVICE_FEE) == SERVICE_FEE else SERVICE_FEE
|
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
user = await get_user((await create_account()).id)
|
user = await get_user((await create_account()).id)
|
||||||
|
|
@ -128,11 +121,14 @@ async def wallet(
|
||||||
return template_renderer().TemplateResponse(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html", {"request": request, "err": "User does not exist."}
|
"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(
|
return template_renderer().TemplateResponse(
|
||||||
"error.html", {"request": request, "err": "User not authorized."}
|
"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
|
user.admin = True
|
||||||
if not wallet_id:
|
if not wallet_id:
|
||||||
if user.wallets and not wallet_name: # type: ignore
|
if user.wallets and not wallet_name: # type: ignore
|
||||||
|
|
@ -163,7 +159,7 @@ async def wallet(
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user.dict(), # type: ignore
|
"user": user.dict(), # type: ignore
|
||||||
"wallet": userwallet.dict(),
|
"wallet": userwallet.dict(),
|
||||||
"service_fee": service_fee,
|
"service_fee": settings.lnbits_service_fee,
|
||||||
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
"web_manifest": f"/manifest/{user.id}.webmanifest", # type: ignore
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -185,7 +181,7 @@ async def lnurl_full_withdraw(request: Request):
|
||||||
"k1": "0",
|
"k1": "0",
|
||||||
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
"minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
|
||||||
"maxWithdrawable": wallet.withdrawable_balance,
|
"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),
|
"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)
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"short_name": LNBITS_SITE_TITLE,
|
"short_name": settings.lnbits_site_title,
|
||||||
"name": LNBITS_SITE_TITLE + " Wallet",
|
"name": settings.lnbits_site_title + " Wallet",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": LNBITS_CUSTOM_LOGO
|
"src": settings.lnbits_custom_logo
|
||||||
if LNBITS_CUSTOM_LOGO
|
if settings.lnbits_custom_logo
|
||||||
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "900x900",
|
"sizes": "900x900",
|
||||||
|
|
@ -311,3 +307,19 @@ async def manifest(usr: str):
|
||||||
for wallet in user.wallets
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from urllib.parse import urlparse
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
|
|
||||||
|
|
|
||||||
63
lnbits/db.py
63
lnbits/db.py
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -10,7 +11,7 @@ from sqlalchemy import create_engine
|
||||||
from sqlalchemy_aio.base import AsyncConnection
|
from sqlalchemy_aio.base import AsyncConnection
|
||||||
from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore
|
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"
|
POSTGRES = "POSTGRES"
|
||||||
COCKROACH = "COCKROACH"
|
COCKROACH = "COCKROACH"
|
||||||
|
|
@ -28,6 +29,13 @@ class Compat:
|
||||||
return f"{seconds}"
|
return f"{seconds}"
|
||||||
return "<nothing>"
|
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
|
@property
|
||||||
def timestamp_now(self) -> str:
|
def timestamp_now(self) -> str:
|
||||||
if self.type in {POSTGRES, COCKROACH}:
|
if self.type in {POSTGRES, COCKROACH}:
|
||||||
|
|
@ -73,26 +81,48 @@ class Connection(Compat):
|
||||||
query = query.replace("?", "%s")
|
query = query.replace("?", "%s")
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
def rewrite_values(self, values):
|
||||||
|
# strip html
|
||||||
|
CLEANR = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
|
||||||
|
|
||||||
|
def cleanhtml(raw_html):
|
||||||
|
if isinstance(raw_html, str):
|
||||||
|
cleantext = re.sub(CLEANR, "", raw_html)
|
||||||
|
return cleantext
|
||||||
|
else:
|
||||||
|
return raw_html
|
||||||
|
|
||||||
|
# tuple to list and back to tuple
|
||||||
|
value_list = [values] if isinstance(values, str) else list(values)
|
||||||
|
values = tuple([cleanhtml(l) for l in value_list])
|
||||||
|
return values
|
||||||
|
|
||||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||||
result = await self.conn.execute(self.rewrite_query(query), values)
|
result = await self.conn.execute(
|
||||||
|
self.rewrite_query(query), self.rewrite_values(values)
|
||||||
|
)
|
||||||
return await result.fetchall()
|
return await result.fetchall()
|
||||||
|
|
||||||
async def fetchone(self, query: str, values: tuple = ()):
|
async def fetchone(self, query: str, values: tuple = ()):
|
||||||
result = await self.conn.execute(self.rewrite_query(query), values)
|
result = await self.conn.execute(
|
||||||
|
self.rewrite_query(query), self.rewrite_values(values)
|
||||||
|
)
|
||||||
row = await result.fetchone()
|
row = await result.fetchone()
|
||||||
await result.close()
|
await result.close()
|
||||||
return row
|
return row
|
||||||
|
|
||||||
async def execute(self, query: str, values: tuple = ()):
|
async def execute(self, query: str, values: tuple = ()):
|
||||||
return await self.conn.execute(self.rewrite_query(query), values)
|
return await self.conn.execute(
|
||||||
|
self.rewrite_query(query), self.rewrite_values(values)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Database(Compat):
|
class Database(Compat):
|
||||||
def __init__(self, db_name: str):
|
def __init__(self, db_name: str):
|
||||||
self.name = db_name
|
self.name = db_name
|
||||||
|
|
||||||
if LNBITS_DATABASE_URL:
|
if settings.lnbits_database_url:
|
||||||
database_uri = LNBITS_DATABASE_URL
|
database_uri = settings.lnbits_database_url
|
||||||
|
|
||||||
if database_uri.startswith("cockroachdb://"):
|
if database_uri.startswith("cockroachdb://"):
|
||||||
self.type = COCKROACH
|
self.type = COCKROACH
|
||||||
|
|
@ -102,6 +132,8 @@ class Database(Compat):
|
||||||
import psycopg2 # type: ignore
|
import psycopg2 # type: ignore
|
||||||
|
|
||||||
def _parse_timestamp(value, _):
|
def _parse_timestamp(value, _):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||||
if not "." in value:
|
if not "." in value:
|
||||||
f = "%Y-%m-%d %H:%M:%S"
|
f = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
@ -126,25 +158,20 @@ class Database(Compat):
|
||||||
|
|
||||||
psycopg2.extensions.register_type(
|
psycopg2.extensions.register_type(
|
||||||
psycopg2.extensions.new_type(
|
psycopg2.extensions.new_type(
|
||||||
(1184, 1114),
|
(1184, 1114), "TIMESTAMP2INT", _parse_timestamp
|
||||||
"TIMESTAMP2INT",
|
|
||||||
_parse_timestamp
|
|
||||||
# lambda value, curs: time.mktime(
|
|
||||||
# datetime.datetime.strptime(
|
|
||||||
# value, "%Y-%m-%d %H:%M:%S.%f"
|
|
||||||
# ).timetuple()
|
|
||||||
# ),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if os.path.isdir(LNBITS_DATA_FOLDER):
|
if os.path.isdir(settings.lnbits_data_folder):
|
||||||
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
|
self.path = os.path.join(
|
||||||
|
settings.lnbits_data_folder, f"{self.name}.sqlite3"
|
||||||
|
)
|
||||||
database_uri = f"sqlite:///{self.path}"
|
database_uri = f"sqlite:///{self.path}"
|
||||||
self.type = SQLITE
|
self.type = SQLITE
|
||||||
else:
|
else:
|
||||||
raise NotADirectoryError(
|
raise NotADirectoryError(
|
||||||
f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created"
|
f"LNBITS_DATA_FOLDER named {settings.lnbits_data_folder} was not created"
|
||||||
f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again"
|
f" - please 'mkdir {settings.lnbits_data_folder}' and try again"
|
||||||
)
|
)
|
||||||
logger.trace(f"database {self.type} added for {self.name}")
|
logger.trace(f"database {self.type} added for {self.name}")
|
||||||
self.schema = 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.crud import get_user, get_wallet_for_key
|
||||||
from lnbits.core.models import User, Wallet
|
from lnbits.core.models import User, Wallet
|
||||||
from lnbits.requestvars import g
|
from lnbits.requestvars import g
|
||||||
from lnbits.settings import (
|
from lnbits.settings import settings
|
||||||
LNBITS_ADMIN_EXTENSIONS,
|
|
||||||
LNBITS_ADMIN_USERS,
|
|
||||||
LNBITS_ALLOWED_USERS,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class KeyChecker(SecurityBase):
|
class KeyChecker(SecurityBase):
|
||||||
|
|
@ -150,8 +146,12 @@ async def get_key_type(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS
|
wallet.wallet.user != settings.super_user
|
||||||
) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
and wallet.wallet.user not in settings.lnbits_admin_users
|
||||||
|
) and (
|
||||||
|
settings.lnbits_admin_extensions
|
||||||
|
and pathname in settings.lnbits_admin_extensions
|
||||||
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN,
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
detail="User not authorized for this extension.",
|
detail="User not authorized for this extension.",
|
||||||
|
|
@ -227,17 +227,45 @@ async def require_invoice_key(
|
||||||
|
|
||||||
async def check_user_exists(usr: UUID4) -> User:
|
async def check_user_exists(usr: UUID4) -> User:
|
||||||
g().user = await get_user(usr.hex)
|
g().user = await get_user(usr.hex)
|
||||||
|
|
||||||
if not g().user:
|
if not g().user:
|
||||||
raise HTTPException(
|
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(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -95,4 +95,4 @@ async def api_bleskomat_delete(
|
||||||
)
|
)
|
||||||
|
|
||||||
await delete_bleskomat(bleskomat_id)
|
await delete_bleskomat(bleskomat_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
|
||||||
|
|
@ -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!***
|
**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
|
## 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!***
|
***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.
|
## Setting the card - Boltcard NFC Card Creator (easy way)
|
||||||
- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}`
|
Updated for v0.1.3
|
||||||
- `{external_id}` should be replaced with the External ID found in the LNBits dialog.
|
|
||||||
|
|
||||||
- Add new card in the extension.
|
- 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 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 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.
|
- 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.
|
- 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
|
- Advanced Options
|
||||||
- Card Keys (k0, k1, k2) will be automatically generated if not explicitly set.
|
- 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.
|
- 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. If there is "debug" in the card name, a debug set of keys is filled instead.
|
- GENERATE KEY button fill the keys randomly.
|
||||||
- Click CREATE CARD button
|
- 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 QR code button next to a card to view its details. Backup the keys now! They'll be comfortable in your password manager.
|
||||||
- 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.
|
- Now you can scan the QR code with the Android app (Create Bolt Card -> SCAN QR CODE).
|
||||||
- Tap the NFC card to write the keys to the card.
|
- 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)
|
## 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`
|
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)
|
## Setting the card - android NXP app (hard way)
|
||||||
- If you don't know the card ID, use NXP TagInfo app to find it out.
|
- 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
|
- Save & Write
|
||||||
- Scan with compatible Wallet
|
- 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]:
|
async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]:
|
||||||
|
if len(cards_ids) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
q = ",".join(["?"] * len(cards_ids))
|
q = ",".join(["?"] * len(cards_ids))
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
f"SELECT * FROM boltcards.hits WHERE card_id IN ({q})", (*cards_ids,)
|
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]:
|
async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]:
|
||||||
|
if len(hits_ids) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
q = ",".join(["?"] * len(hits_ids))
|
q = ",".join(["?"] * len(hits_ids))
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
f"SELECT * FROM boltcards.refunds WHERE hit_id IN ({q})", (*hits_ids,)
|
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 json
|
||||||
import secrets
|
import secrets
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from io import BytesIO
|
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from embit import bech32, compact
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.param_functions import Query
|
from fastapi.param_functions import Query
|
||||||
from fastapi.params import Depends, Query
|
from fastapi.params import Depends, Query
|
||||||
from lnurl import Lnurl, LnurlWithdrawResponse
|
|
||||||
from lnurl import encode as lnurl_encode # type: ignore
|
from lnurl import encode as lnurl_encode # type: ignore
|
||||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||||
from loguru import logger
|
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
@ -33,7 +25,6 @@ from .crud import (
|
||||||
get_hit,
|
get_hit,
|
||||||
get_hits_today,
|
get_hits_today,
|
||||||
spend_hit,
|
spend_hit,
|
||||||
update_card,
|
|
||||||
update_card_counter,
|
update_card_counter,
|
||||||
update_card_otp,
|
update_card_otp,
|
||||||
)
|
)
|
||||||
|
|
@ -108,15 +99,27 @@ async def lnurl_callback(
|
||||||
pr: str = Query(None),
|
pr: str = Query(None),
|
||||||
k1: str = Query(None),
|
k1: str = Query(None),
|
||||||
):
|
):
|
||||||
|
if not k1:
|
||||||
|
return {"status": "ERROR", "reason": "Missing K1 token"}
|
||||||
|
|
||||||
hit = await get_hit(k1)
|
hit = await get_hit(k1)
|
||||||
card = await get_card(hit.card_id)
|
|
||||||
if not hit:
|
if not hit:
|
||||||
return {"status": "ERROR", "reason": f"LNURL-pay record not found."}
|
return {
|
||||||
if hit.id != k1:
|
"status": "ERROR",
|
||||||
return {"status": "ERROR", "reason": "Bad K1"}
|
"reason": "Record not found for this charge (bad k1)",
|
||||||
|
}
|
||||||
if hit.spent:
|
if hit.spent:
|
||||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
return {"status": "ERROR", "reason": "Payment already claimed"}
|
||||||
|
if not pr:
|
||||||
|
return {"status": "ERROR", "reason": "Missing payment request"}
|
||||||
|
|
||||||
|
try:
|
||||||
invoice = bolt11.decode(pr)
|
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))
|
hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000))
|
||||||
try:
|
try:
|
||||||
await pay_invoice(
|
await pay_invoice(
|
||||||
|
|
@ -126,8 +129,8 @@ async def lnurl_callback(
|
||||||
extra={"tag": "boltcard", "tag": hit.id},
|
extra={"tag": "boltcard", "tag": hit.id},
|
||||||
)
|
)
|
||||||
return {"status": "OK"}
|
return {"status": "OK"}
|
||||||
except:
|
except Exception as exc:
|
||||||
return {"status": "ERROR", "reason": f"Payment failed"}
|
return {"status": "ERROR", "reason": f"Payment failed - {exc}"}
|
||||||
|
|
||||||
|
|
||||||
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
# /boltcards/api/v1/auth?a=00000000000000000000000000000000
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,7 @@ new Vue({
|
||||||
},
|
},
|
||||||
qrCodeDialog: {
|
qrCodeDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
|
wipe: false,
|
||||||
data: null
|
data: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -259,9 +260,10 @@ new Vue({
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
openQrCodeDialog(cardId) {
|
openQrCodeDialog(cardId, wipe) {
|
||||||
var card = _.findWhere(this.cards, {id: cardId})
|
var card = _.findWhere(this.cards, {id: cardId})
|
||||||
this.qrCodeDialog.data = {
|
this.qrCodeDialog.data = {
|
||||||
|
id: card.id,
|
||||||
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp,
|
link: window.location.origin + '/boltcards/api/v1/auth?a=' + card.otp,
|
||||||
name: card.card_name,
|
name: card.card_name,
|
||||||
uid: card.uid,
|
uid: card.uid,
|
||||||
|
|
@ -272,6 +274,17 @@ new Vue({
|
||||||
k3: card.k1,
|
k3: card.k1,
|
||||||
k4: card.k2
|
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
|
this.qrCodeDialog.show = true
|
||||||
},
|
},
|
||||||
addCardOpen: function () {
|
addCardOpen: function () {
|
||||||
|
|
@ -397,8 +410,16 @@ new Vue({
|
||||||
let self = this
|
let self = this
|
||||||
let cards = _.findWhere(this.cards, {id: cardId})
|
let cards = _.findWhere(this.cards, {id: cardId})
|
||||||
|
|
||||||
|
Quasar.utils.exportFile(
|
||||||
|
cards.card_name + '.json',
|
||||||
|
this.qrCodeDialog.data_wipe,
|
||||||
|
'application/json'
|
||||||
|
)
|
||||||
|
|
||||||
LNbits.utils
|
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 () {
|
.onOk(function () {
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@
|
||||||
</q-th>
|
</q-th>
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
<q-th auto-width></q-th>
|
<q-th auto-width></q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
</q-tr>
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:body="props">
|
<template v-slot:body="props">
|
||||||
|
|
@ -58,7 +59,7 @@
|
||||||
dense
|
dense
|
||||||
icon="qr_code"
|
icon="qr_code"
|
||||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
: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-tooltip>Card key credentials</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
|
@ -99,7 +100,7 @@
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
size="xs"
|
size="xs"
|
||||||
@click="deleteCard(props.row.id)"
|
@click="openQrCodeDialog(props.row.id, true)"
|
||||||
icon="cancel"
|
icon="cancel"
|
||||||
color="pink"
|
color="pink"
|
||||||
>
|
>
|
||||||
|
|
@ -215,6 +216,7 @@
|
||||||
emit-value
|
emit-value
|
||||||
v-model="cardDialog.data.wallet"
|
v-model="cardDialog.data.wallet"
|
||||||
:options="g.user.walletOptions"
|
:options="g.user.walletOptions"
|
||||||
|
:disable="cardDialog.data.id != null"
|
||||||
label="Wallet *"
|
label="Wallet *"
|
||||||
>
|
>
|
||||||
</q-select>
|
</q-select>
|
||||||
|
|
@ -283,7 +285,7 @@
|
||||||
v-model="toggleAdvanced"
|
v-model="toggleAdvanced"
|
||||||
label="Show advanced options"
|
label="Show advanced options"
|
||||||
></q-toggle>
|
></q-toggle>
|
||||||
<div v-show="toggleAdvanced">
|
<div v-show="toggleAdvanced" class="q-gutter-y-md">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -358,40 +360,105 @@
|
||||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||||
{% raw %}
|
{% raw %}
|
||||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
<div class="col q-mt-lg text-center">
|
||||||
|
<q-responsive
|
||||||
|
:ratio="1"
|
||||||
|
class="q-mx-xl q-mb-md"
|
||||||
|
v-show="!qrCodeDialog.wipe"
|
||||||
|
>
|
||||||
<qrcode
|
<qrcode
|
||||||
:value="qrCodeDialog.data.link"
|
:value="qrCodeDialog.data.link"
|
||||||
:options="{width: 800}"
|
:options="{width: 800}"
|
||||||
class="rounded-borders"
|
class="rounded-borders"
|
||||||
></qrcode>
|
></qrcode>
|
||||||
</q-responsive>
|
</q-responsive>
|
||||||
<p style="word-break: break-all" class="text-center">
|
<p class="text-center" v-show="!qrCodeDialog.wipe">
|
||||||
(Keys for
|
(QR for <strong>create</strong> the card in
|
||||||
<a
|
<a
|
||||||
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
href="https://play.google.com/store/apps/details?id=com.lightningnfcapp"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>bolt-nfc-android-app</a
|
style="color: inherit"
|
||||||
|
>Boltcard NFC Card Creator</a
|
||||||
>)
|
>)
|
||||||
</p>
|
</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">
|
<p style="word-break: break-all">
|
||||||
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br />
|
<strong>Name:</strong> {{ qrCodeDialog.data.name }}<br />
|
||||||
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br />
|
<strong>UID:</strong> {{ qrCodeDialog.data.uid }}<br />
|
||||||
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br />
|
<strong>External ID:</strong> {{ qrCodeDialog.data.external_id }}<br />
|
||||||
<strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br />
|
<strong>Lock key (K0):</strong> {{ qrCodeDialog.data.k0 }}<br />
|
||||||
<strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br />
|
<strong>Meta key (K1 & K3):</strong> {{ qrCodeDialog.data.k1 }}<br />
|
||||||
<strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<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>
|
</p>
|
||||||
<br />
|
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
outline
|
outline
|
||||||
color="grey"
|
color="grey"
|
||||||
@click="copyText(qrCodeDialog.data.link)"
|
@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-btn>
|
||||||
<q-tooltip>Click to copy, then add to NFC card</q-tooltip>
|
|
||||||
|
|
||||||
{% endraw %}
|
{% endraw %}
|
||||||
<div class="row q-mt-lg q-gutter-sm">
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
<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 . import boltcards_ext
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_card,
|
create_card,
|
||||||
create_hit,
|
|
||||||
delete_card,
|
delete_card,
|
||||||
enable_disable_card,
|
enable_disable_card,
|
||||||
get_card,
|
get_card,
|
||||||
|
|
@ -22,11 +21,9 @@ from .crud import (
|
||||||
get_hits,
|
get_hits,
|
||||||
get_refunds,
|
get_refunds,
|
||||||
update_card,
|
update_card,
|
||||||
update_card_counter,
|
|
||||||
update_card_otp,
|
update_card_otp,
|
||||||
)
|
)
|
||||||
from .models import CreateCardData
|
from .models import CreateCardData
|
||||||
from .nxp424 import decryptSUN, getSunMAC
|
|
||||||
|
|
||||||
|
|
||||||
@boltcards_ext.get("/api/v1/cards")
|
@boltcards_ext.get("/api/v1/cards")
|
||||||
|
|
@ -129,7 +126,7 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi
|
||||||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||||
|
|
||||||
await delete_card(card_id)
|
await delete_card(card_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
@boltcards_ext.get("/api/v1/hits")
|
@boltcards_ext.get("/api/v1/hits")
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.services import create_invoice, pay_invoice
|
from lnbits.core.services import create_invoice, pay_invoice
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
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 .crud import update_swap_status
|
||||||
from .mempool import (
|
from .mempool import (
|
||||||
|
|
@ -33,9 +33,7 @@ from .models import (
|
||||||
)
|
)
|
||||||
from .utils import check_balance, get_timestamp, req_wrap
|
from .utils import check_balance, get_timestamp, req_wrap
|
||||||
|
|
||||||
net = NETWORKS[BOLTZ_NETWORK]
|
net = NETWORKS[settings.boltz_network]
|
||||||
logger.trace(f"BOLTZ_URL: {BOLTZ_URL}")
|
|
||||||
logger.trace(f"Bitcoin Network: {net['name']}")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
|
async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
|
||||||
|
|
@ -62,7 +60,7 @@ async def create_swap(data: CreateSubmarineSwap) -> SubmarineSwap:
|
||||||
|
|
||||||
res = req_wrap(
|
res = req_wrap(
|
||||||
"post",
|
"post",
|
||||||
f"{BOLTZ_URL}/createswap",
|
f"{settings.boltz_url}/createswap",
|
||||||
json={
|
json={
|
||||||
"type": "submarine",
|
"type": "submarine",
|
||||||
"pairId": "BTC/BTC",
|
"pairId": "BTC/BTC",
|
||||||
|
|
@ -129,7 +127,7 @@ async def create_reverse_swap(
|
||||||
|
|
||||||
res = req_wrap(
|
res = req_wrap(
|
||||||
"post",
|
"post",
|
||||||
f"{BOLTZ_URL}/createswap",
|
f"{settings.boltz_url}/createswap",
|
||||||
json={
|
json={
|
||||||
"type": "reversesubmarine",
|
"type": "reversesubmarine",
|
||||||
"pairId": "BTC/BTC",
|
"pairId": "BTC/BTC",
|
||||||
|
|
@ -409,7 +407,7 @@ def check_boltz_limits(amount):
|
||||||
def get_boltz_pairs():
|
def get_boltz_pairs():
|
||||||
res = req_wrap(
|
res = req_wrap(
|
||||||
"get",
|
"get",
|
||||||
f"{BOLTZ_URL}/getpairs",
|
f"{settings.boltz_url}/getpairs",
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
@ -418,7 +416,7 @@ def get_boltz_pairs():
|
||||||
def get_boltz_status(boltzid):
|
def get_boltz_status(boltzid):
|
||||||
res = req_wrap(
|
res = req_wrap(
|
||||||
"post",
|
"post",
|
||||||
f"{BOLTZ_URL}/swapstatus",
|
f"{settings.boltz_url}/swapstatus",
|
||||||
json={"id": boltzid},
|
json={"id": boltzid},
|
||||||
)
|
)
|
||||||
return res.json()
|
return res.json()
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,11 @@ import websockets
|
||||||
from embit.transaction import Transaction
|
from embit.transaction import Transaction
|
||||||
from loguru import logger
|
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
|
from .utils import req_wrap
|
||||||
|
|
||||||
logger.trace(f"BOLTZ_MEMPOOL_SPACE_URL: {BOLTZ_MEMPOOL_SPACE_URL}")
|
websocket_url = f"{settings.boltz_mempool_space_url_ws}/api/v1/ws"
|
||||||
logger.trace(f"BOLTZ_MEMPOOL_SPACE_URL_WS: {BOLTZ_MEMPOOL_SPACE_URL_WS}")
|
|
||||||
|
|
||||||
websocket_url = f"{BOLTZ_MEMPOOL_SPACE_URL_WS}/api/v1/ws"
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_websocket_message(send, message_string):
|
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):
|
def get_mempool_tx(address):
|
||||||
res = req_wrap(
|
res = req_wrap(
|
||||||
"get",
|
"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"},
|
headers={"Content-Type": "text/plain"},
|
||||||
)
|
)
|
||||||
txs = res.json()
|
txs = res.json()
|
||||||
|
|
@ -70,7 +67,7 @@ def get_fee_estimation() -> int:
|
||||||
def get_mempool_fees() -> int:
|
def get_mempool_fees() -> int:
|
||||||
res = req_wrap(
|
res = req_wrap(
|
||||||
"get",
|
"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"},
|
headers={"Content-Type": "text/plain"},
|
||||||
)
|
)
|
||||||
fees = res.json()
|
fees = res.json()
|
||||||
|
|
@ -80,7 +77,7 @@ def get_mempool_fees() -> int:
|
||||||
def get_mempool_blockheight() -> int:
|
def get_mempool_blockheight() -> int:
|
||||||
res = req_wrap(
|
res = req_wrap(
|
||||||
"get",
|
"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"},
|
headers={"Content-Type": "text/plain"},
|
||||||
)
|
)
|
||||||
return int(res.text)
|
return int(res.text)
|
||||||
|
|
@ -91,7 +88,7 @@ async def send_onchain_tx(tx: Transaction):
|
||||||
logger.debug(f"Boltz - mempool sending onchain tx...")
|
logger.debug(f"Boltz - mempool sending onchain tx...")
|
||||||
req_wrap(
|
req_wrap(
|
||||||
"post",
|
"post",
|
||||||
f"{BOLTZ_MEMPOOL_SPACE_URL}/api/tx",
|
f"{settings.boltz_mempool_space_url}/api/tx",
|
||||||
headers={"Content-Type": "text/plain"},
|
headers={"Content-Type": "text/plain"},
|
||||||
content=raw,
|
content=raw,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from starlette.requests import Request
|
||||||
|
|
||||||
from lnbits.core.crud import get_user
|
from lnbits.core.crud import get_user
|
||||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
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 . import boltz_ext
|
||||||
from .boltz import (
|
from .boltz import (
|
||||||
|
|
@ -55,7 +55,7 @@ from .utils import check_balance
|
||||||
response_model=str,
|
response_model=str,
|
||||||
)
|
)
|
||||||
async def api_mempool_url():
|
async def api_mempool_url():
|
||||||
return BOLTZ_MEMPOOL_SPACE_URL
|
return settings.boltz_mempool_space_url
|
||||||
|
|
||||||
|
|
||||||
# NORMAL SWAP
|
# NORMAL SWAP
|
||||||
|
|
|
||||||
11
lnbits/extensions/cashu/README.md
Normal file
11
lnbits/extensions/cashu/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Cashu
|
||||||
|
|
||||||
|
## Create ecash mint for pegging in/out of ecash
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
1. Enable extension
|
||||||
|
2. Create a Mint
|
||||||
|
3. Share wallet
|
||||||
48
lnbits/extensions/cashu/__init__.py
Normal file
48
lnbits/extensions/cashu/__init__.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from environs import Env # type: ignore
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
from lnbits.tasks import catch_everything_and_restart
|
||||||
|
|
||||||
|
db = Database("ext_cashu")
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
cashu_static_files = [
|
||||||
|
{
|
||||||
|
"path": "/cashu/static",
|
||||||
|
"app": StaticFiles(directory="lnbits/extensions/cashu/static"),
|
||||||
|
"name": "cashu_static",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
from cashu.mint.ledger import Ledger
|
||||||
|
|
||||||
|
env = Env()
|
||||||
|
env.read_env()
|
||||||
|
|
||||||
|
ledger = Ledger(
|
||||||
|
db=db,
|
||||||
|
seed=env.str("CASHU_PRIVATE_KEY", default="SuperSecretPrivateKey"),
|
||||||
|
derivation_path="0/0/0/1",
|
||||||
|
)
|
||||||
|
|
||||||
|
cashu_ext: APIRouter = APIRouter(prefix="/cashu", tags=["cashu"])
|
||||||
|
|
||||||
|
|
||||||
|
def cashu_renderer():
|
||||||
|
return template_renderer(["lnbits/extensions/cashu/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .tasks import startup_cashu_mint, wait_for_paid_invoices
|
||||||
|
from .views import * # noqa
|
||||||
|
from .views_api import * # noqa
|
||||||
|
|
||||||
|
|
||||||
|
def cashu_start():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(catch_everything_and_restart(startup_cashu_mint))
|
||||||
|
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||||
7
lnbits/extensions/cashu/config.json
Normal file
7
lnbits/extensions/cashu/config.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"name": "Cashu",
|
||||||
|
"short_description": "Ecash mint and wallet",
|
||||||
|
"icon": "account_balance",
|
||||||
|
"contributors": ["calle", "vlad", "arcbtc"],
|
||||||
|
"hidden": false
|
||||||
|
}
|
||||||
63
lnbits/extensions/cashu/crud.py
Normal file
63
lnbits/extensions/cashu/crud.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from binascii import hexlify, unhexlify
|
||||||
|
from typing import Any, List, Optional, Union
|
||||||
|
|
||||||
|
from cashu.core.base import MintKeyset
|
||||||
|
from embit import bip32, bip39, ec, script
|
||||||
|
from embit.networks import NETWORKS
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.db import Connection, Database
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .models import Cashu, Pegs, Promises, Proof
|
||||||
|
|
||||||
|
|
||||||
|
async def create_cashu(
|
||||||
|
cashu_id: str, keyset_id: str, wallet_id: str, data: Cashu
|
||||||
|
) -> Cashu:
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cashu.cashu (id, wallet, name, tickershort, fraction, maxsats, coins, keyset_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
cashu_id,
|
||||||
|
wallet_id,
|
||||||
|
data.name,
|
||||||
|
data.tickershort,
|
||||||
|
data.fraction,
|
||||||
|
data.maxsats,
|
||||||
|
data.coins,
|
||||||
|
keyset_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
cashu = await get_cashu(cashu_id)
|
||||||
|
assert cashu, "Newly created cashu couldn't be retrieved"
|
||||||
|
return cashu
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cashu(cashu_id) -> Optional[Cashu]:
|
||||||
|
row = await db.fetchone("SELECT * FROM cashu.cashu WHERE id = ?", (cashu_id,))
|
||||||
|
return Cashu(**row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cashus(wallet_ids: Union[str, List[str]]) -> List[Cashu]:
|
||||||
|
if isinstance(wallet_ids, str):
|
||||||
|
wallet_ids = [wallet_ids]
|
||||||
|
|
||||||
|
q = ",".join(["?"] * len(wallet_ids))
|
||||||
|
rows = await db.fetchall(
|
||||||
|
f"SELECT * FROM cashu.cashu WHERE wallet IN ({q})", (*wallet_ids,)
|
||||||
|
)
|
||||||
|
|
||||||
|
return [Cashu(**row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_cashu(cashu_id) -> None:
|
||||||
|
await db.execute("DELETE FROM cashu.cashu WHERE id = ?", (cashu_id,))
|
||||||
33
lnbits/extensions/cashu/migrations.py
Normal file
33
lnbits/extensions/cashu/migrations.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial cashu table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE cashu.cashu (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
tickershort TEXT DEFAULT 'sats',
|
||||||
|
fraction BOOL,
|
||||||
|
maxsats INT,
|
||||||
|
coins INT,
|
||||||
|
keyset_id TEXT NOT NULL,
|
||||||
|
issued_sat INT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
"""
|
||||||
|
Initial cashus table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE cashu.pegs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
inout BOOL NOT NULL,
|
||||||
|
amount INT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
147
lnbits/extensions/cashu/models.py
Normal file
147
lnbits/extensions/cashu/models.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
from sqlite3 import Row
|
||||||
|
from typing import List, Union
|
||||||
|
|
||||||
|
from fastapi import Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Cashu(BaseModel):
|
||||||
|
id: str = Query(None)
|
||||||
|
name: str = Query(None)
|
||||||
|
wallet: str = Query(None)
|
||||||
|
tickershort: str = Query(None)
|
||||||
|
fraction: bool = Query(None)
|
||||||
|
maxsats: int = Query(0)
|
||||||
|
coins: int = Query(0)
|
||||||
|
keyset_id: str = Query(None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row):
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class Pegs(BaseModel):
|
||||||
|
id: str
|
||||||
|
wallet: str
|
||||||
|
inout: str
|
||||||
|
amount: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row):
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class PayLnurlWData(BaseModel):
|
||||||
|
lnurl: str
|
||||||
|
|
||||||
|
|
||||||
|
class Promises(BaseModel):
|
||||||
|
id: str
|
||||||
|
amount: int
|
||||||
|
B_b: str
|
||||||
|
C_b: str
|
||||||
|
cashu_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class Proof(BaseModel):
|
||||||
|
amount: int
|
||||||
|
secret: str
|
||||||
|
C: str
|
||||||
|
reserved: bool = False # whether this proof is reserved for sending
|
||||||
|
send_id: str = "" # unique ID of send attempt
|
||||||
|
time_created: str = ""
|
||||||
|
time_reserved: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row):
|
||||||
|
return cls(
|
||||||
|
amount=row[0],
|
||||||
|
C=row[1],
|
||||||
|
secret=row[2],
|
||||||
|
reserved=row[3] or False,
|
||||||
|
send_id=row[4] or "",
|
||||||
|
time_created=row[5] or "",
|
||||||
|
time_reserved=row[6] or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict):
|
||||||
|
assert "secret" in d, "no secret in proof"
|
||||||
|
assert "amount" in d, "no amount in proof"
|
||||||
|
return cls(
|
||||||
|
amount=d.get("amount"),
|
||||||
|
C=d.get("C"),
|
||||||
|
secret=d.get("secret"),
|
||||||
|
reserved=d.get("reserved") or False,
|
||||||
|
send_id=d.get("send_id") or "",
|
||||||
|
time_created=d.get("time_created") or "",
|
||||||
|
time_reserved=d.get("time_reserved") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return dict(amount=self.amount, secret=self.secret, C=self.C)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self.__getattribute__(key)
|
||||||
|
|
||||||
|
def __setitem__(self, key, val):
|
||||||
|
self.__setattr__(key, val)
|
||||||
|
|
||||||
|
|
||||||
|
class Proofs(BaseModel):
|
||||||
|
"""TODO: Use this model"""
|
||||||
|
|
||||||
|
proofs: List[Proof]
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(BaseModel):
|
||||||
|
amount: int
|
||||||
|
pr: str
|
||||||
|
hash: str
|
||||||
|
issued: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row):
|
||||||
|
return cls(
|
||||||
|
amount=int(row[0]),
|
||||||
|
pr=str(row[1]),
|
||||||
|
hash=str(row[2]),
|
||||||
|
issued=bool(row[3]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BlindedMessage(BaseModel):
|
||||||
|
amount: int
|
||||||
|
B_: str
|
||||||
|
|
||||||
|
|
||||||
|
class BlindedSignature(BaseModel):
|
||||||
|
amount: int
|
||||||
|
C_: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict):
|
||||||
|
return cls(
|
||||||
|
amount=d["amount"],
|
||||||
|
C_=d["C_"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MintPayloads(BaseModel):
|
||||||
|
blinded_messages: List[BlindedMessage] = []
|
||||||
|
|
||||||
|
|
||||||
|
class SplitPayload(BaseModel):
|
||||||
|
proofs: List[Proof]
|
||||||
|
amount: int
|
||||||
|
output_data: MintPayloads
|
||||||
|
|
||||||
|
|
||||||
|
class CheckPayload(BaseModel):
|
||||||
|
proofs: List[Proof]
|
||||||
|
|
||||||
|
|
||||||
|
class MeltPayload(BaseModel):
|
||||||
|
proofs: List[Proof]
|
||||||
|
amount: int
|
||||||
|
invoice: str
|
||||||
37
lnbits/extensions/cashu/static/js/base64.js
Normal file
37
lnbits/extensions/cashu/static/js/base64.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
function unescapeBase64Url(str) {
|
||||||
|
return (str + '==='.slice((str.length + 3) % 4))
|
||||||
|
.replace(/-/g, '+')
|
||||||
|
.replace(/_/g, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeBase64Url(str) {
|
||||||
|
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8ToBase64 = (function (exports) {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
var fromCharCode = String.fromCharCode
|
||||||
|
var encode = function encode(uint8array) {
|
||||||
|
var output = []
|
||||||
|
|
||||||
|
for (var i = 0, length = uint8array.length; i < length; i++) {
|
||||||
|
output.push(fromCharCode(uint8array[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(output.join(''))
|
||||||
|
}
|
||||||
|
|
||||||
|
var asCharCode = function asCharCode(c) {
|
||||||
|
return c.charCodeAt(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decode = function decode(chars) {
|
||||||
|
return Uint8Array.from(atob(chars), asCharCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.decode = decode
|
||||||
|
exports.encode = encode
|
||||||
|
|
||||||
|
return exports
|
||||||
|
})({})
|
||||||
39
lnbits/extensions/cashu/static/js/dhke.js
Normal file
39
lnbits/extensions/cashu/static/js/dhke.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
async function hashToCurve(secretMessage) {
|
||||||
|
console.log(
|
||||||
|
'### secretMessage',
|
||||||
|
nobleSecp256k1.utils.bytesToHex(secretMessage)
|
||||||
|
)
|
||||||
|
let point
|
||||||
|
while (!point) {
|
||||||
|
const hash = await nobleSecp256k1.utils.sha256(secretMessage)
|
||||||
|
const hashHex = nobleSecp256k1.utils.bytesToHex(hash)
|
||||||
|
const pointX = '02' + hashHex
|
||||||
|
console.log('### pointX', pointX)
|
||||||
|
try {
|
||||||
|
point = nobleSecp256k1.Point.fromHex(pointX)
|
||||||
|
console.log('### point', point.toHex())
|
||||||
|
} catch (error) {
|
||||||
|
secretMessage = await nobleSecp256k1.utils.sha256(secretMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
|
||||||
|
async function step1Alice(secretMessage) {
|
||||||
|
// todo: document & validate `secretMessage` format
|
||||||
|
secretMessage = uint8ToBase64.encode(secretMessage)
|
||||||
|
secretMessage = new TextEncoder().encode(secretMessage)
|
||||||
|
const Y = await hashToCurve(secretMessage)
|
||||||
|
const rpk = nobleSecp256k1.utils.randomPrivateKey()
|
||||||
|
const r = bytesToNumber(rpk)
|
||||||
|
const P = nobleSecp256k1.Point.fromPrivateKey(r)
|
||||||
|
const B_ = Y.add(P)
|
||||||
|
return {B_: B_.toHex(true), r: nobleSecp256k1.utils.bytesToHex(rpk)}
|
||||||
|
}
|
||||||
|
|
||||||
|
function step3Alice(C_, r, A) {
|
||||||
|
// const rInt = BigInt(r)
|
||||||
|
const rInt = bytesToNumber(r)
|
||||||
|
const C = C_.subtract(A.multiply(rInt))
|
||||||
|
return C
|
||||||
|
}
|
||||||
1178
lnbits/extensions/cashu/static/js/noble-secp256k1.js
Normal file
1178
lnbits/extensions/cashu/static/js/noble-secp256k1.js
Normal file
File diff suppressed because it is too large
Load diff
23
lnbits/extensions/cashu/static/js/utils.js
Normal file
23
lnbits/extensions/cashu/static/js/utils.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
function splitAmount(value) {
|
||||||
|
const chunks = []
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
const mask = 1 << i
|
||||||
|
if ((value & mask) !== 0) chunks.push(Math.pow(2, i))
|
||||||
|
}
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToNumber(bytes) {
|
||||||
|
return hexToNumber(nobleSecp256k1.utils.bytesToHex(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
function bigIntStringify(key, value) {
|
||||||
|
return typeof value === 'bigint' ? value.toString() : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToNumber(hex) {
|
||||||
|
if (typeof hex !== 'string') {
|
||||||
|
throw new TypeError('hexToNumber: expected string, got ' + typeof hex)
|
||||||
|
}
|
||||||
|
return BigInt(`0x${hex}`)
|
||||||
|
}
|
||||||
33
lnbits/extensions/cashu/tasks.py
Normal file
33
lnbits/extensions/cashu/tasks.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from cashu.core.migrations import migrate_databases
|
||||||
|
from cashu.mint import migrations
|
||||||
|
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
|
||||||
|
from . import db, ledger
|
||||||
|
from .crud import get_cashu
|
||||||
|
|
||||||
|
|
||||||
|
async def startup_cashu_mint():
|
||||||
|
await migrate_databases(db, migrations)
|
||||||
|
await ledger.load_used_proofs()
|
||||||
|
await ledger.init_keysets(autosave=False)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_paid_invoices():
|
||||||
|
invoice_queue = asyncio.Queue()
|
||||||
|
register_invoice_listener(invoice_queue)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
if payment.extra and not payment.extra.get("tag") == "cashu":
|
||||||
|
return
|
||||||
|
return
|
||||||
80
lnbits/extensions/cashu/templates/cashu/_api_docs.html
Normal file
80
lnbits/extensions/cashu/templates/cashu/_api_docs.html
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<q-expansion-item
|
||||||
|
group="extras"
|
||||||
|
icon="swap_vertical_circle"
|
||||||
|
label="API info"
|
||||||
|
:content-inset-level="0.5"
|
||||||
|
>
|
||||||
|
<q-btn flat label="Swagger API" type="a" href="../docs#/cashu"></q-btn>
|
||||||
|
<!-- <q-expansion-item group="api" dense expand-separator label="List TPoS">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-blue">GET</span> /cashu/api/v1/mints</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 200 OK (application/json)
|
||||||
|
</h5>
|
||||||
|
<code>[<cashu_object>, ...]</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X GET {{ request.base_url }}cashu/api/v1/mints -H "X-Api-Key:
|
||||||
|
<invoice_key>"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
<q-expansion-item group="api" dense expand-separator label="Create a TPoS">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code><span class="text-green">POST</span> /cashu/api/v1/mints</code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||||
|
<code
|
||||||
|
>{"name": <string>, "currency": <string*ie USD*>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||||
|
Returns 201 CREATED (application/json)
|
||||||
|
</h5>
|
||||||
|
<code
|
||||||
|
>{"currency": <string>, "id": <string>, "name":
|
||||||
|
<string>, "wallet": <string>}</code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X POST {{ request.base_url }}cashu/api/v1/mints -d '{"name":
|
||||||
|
<string>, "currency": <string>}' -H "Content-type:
|
||||||
|
application/json" -H "X-Api-Key: <admin_key>"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<q-expansion-item
|
||||||
|
group="api"
|
||||||
|
dense
|
||||||
|
expand-separator
|
||||||
|
label="Delete a TPoS"
|
||||||
|
class="q-pb-md"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<code
|
||||||
|
><span class="text-pink">DELETE</span>
|
||||||
|
/cashu/api/v1/mints/<cashu_id></code
|
||||||
|
>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||||
|
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||||
|
<code></code>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||||
|
<code
|
||||||
|
>curl -X DELETE {{ request.base_url
|
||||||
|
}}cashu/api/v1/mints/<cashu_id> -H "X-Api-Key:
|
||||||
|
<admin_key>"
|
||||||
|
</code>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item> -->
|
||||||
|
</q-expansion-item>
|
||||||
13
lnbits/extensions/cashu/templates/cashu/_cashu.html
Normal file
13
lnbits/extensions/cashu/templates/cashu/_cashu.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<q-expansion-item group="extras" icon="info" label="About">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>Create Cashu ecash mints and wallets.</p>
|
||||||
|
<small
|
||||||
|
>Created by
|
||||||
|
<a href="https://github.com/arcbtc" target="_blank">arcbtc</a>,
|
||||||
|
<a href="https://github.com/motorina0" target="_blank">vlad</a>,
|
||||||
|
<a href="https://github.com/calle" target="_blank">calle</a>.</small
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
367
lnbits/extensions/cashu/templates/cashu/index.html
Normal file
367
lnbits/extensions/cashu/templates/cashu/index.html
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<b>Cashu mint and wallet</b>
|
||||||
|
<p></p>
|
||||||
|
<p>
|
||||||
|
Here you can create multiple cashu mints that you can share. Each mint
|
||||||
|
can service many users but all ecash tokens of a mint are only valid
|
||||||
|
inside that mint and not across different mints. To exchange funds
|
||||||
|
between mints, use Lightning payments.
|
||||||
|
</p>
|
||||||
|
<b>Important</b>
|
||||||
|
<p></p>
|
||||||
|
<p>
|
||||||
|
If you are the operator of this LNbits instance, make sure to set
|
||||||
|
CASHU_PRIVATE_KEY="randomkey" in your configuration file. Do not
|
||||||
|
create mints before setting the key and do not change the key once
|
||||||
|
set.
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col">
|
||||||
|
<h5 class="text-subtitle1 q-my-none">Mints</h5>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-table
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
:data="cashus"
|
||||||
|
row-key="id"
|
||||||
|
:columns="cashusTable.columns"
|
||||||
|
:pagination.sync="cashusTable.pagination"
|
||||||
|
>
|
||||||
|
{% raw %}
|
||||||
|
<template v-slot:header="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ col.label }}
|
||||||
|
</q-th>
|
||||||
|
<q-th auto-width></q-th>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="account_balance_wallet"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'wallet/?' + 'mint_id=' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
><q-tooltip>Shareable wallet</q-tooltip></q-btn
|
||||||
|
>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="account_balance"
|
||||||
|
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||||
|
type="a"
|
||||||
|
:href="'mint/' + props.row.id"
|
||||||
|
target="_blank"
|
||||||
|
><q-tooltip>Shareable mint page</q-tooltip></q-btn
|
||||||
|
>
|
||||||
|
</q-td>
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
{{ (col.name == 'tip_options' && col.value ?
|
||||||
|
JSON.parse(col.value).join(", ") : col.value) }}
|
||||||
|
</q-td>
|
||||||
|
<q-td auto-width>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
@click="deleteMint(props.row.id)"
|
||||||
|
icon="cancel"
|
||||||
|
color="pink"
|
||||||
|
></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
{% endraw %}
|
||||||
|
</q-table>
|
||||||
|
<q-btn
|
||||||
|
class="q-pt-l"
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
@click="formDialog.show = true"
|
||||||
|
>New Mint</q-btn
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Cashu extension</h6>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list>
|
||||||
|
{% include "cashu/_api_docs.html" %}
|
||||||
|
<q-separator></q-separator>
|
||||||
|
{% include "cashu/_cashu.html" %}
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-form @submit="createMint" class="q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.name"
|
||||||
|
label="Mint Name"
|
||||||
|
placeholder="Cashu Mint"
|
||||||
|
></q-input>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="formDialog.data.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Cashu wallet *"
|
||||||
|
></q-select>
|
||||||
|
<!-- <q-toggle
|
||||||
|
v-model="toggleAdvanced"
|
||||||
|
label="Show advanced options"
|
||||||
|
></q-toggle>
|
||||||
|
<div v-show="toggleAdvanced">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-5">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="formDialog.data.fraction"
|
||||||
|
color="primary"
|
||||||
|
label="sats/coins?"
|
||||||
|
>
|
||||||
|
<q-tooltip
|
||||||
|
>Use with hedging extension to create a stablecoin!</q-tooltip
|
||||||
|
>
|
||||||
|
</q-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="col-7">
|
||||||
|
<q-input
|
||||||
|
v-if="!formDialog.data.fraction"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
v-model.trim="formDialog.data.cost"
|
||||||
|
label="Sat coin cost (optional)"
|
||||||
|
value="1"
|
||||||
|
type="number"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
v-if="!formDialog.data.fraction"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.trim="formDialog.data.tickershort"
|
||||||
|
label="Ticker shorthand"
|
||||||
|
placeholder="sats"
|
||||||
|
#
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
class="q-mt-md"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
v-model.trim="formDialog.data.maxsats"
|
||||||
|
label="Maximum mint liquidity (optional)"
|
||||||
|
placeholder="∞"
|
||||||
|
></q-input>
|
||||||
|
<q-input
|
||||||
|
class="q-mt-md"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
v-model.trim="formDialog.data.coins"
|
||||||
|
label="Coins that 'exist' in mint (optional)"
|
||||||
|
placeholder="∞"
|
||||||
|
></q-input>
|
||||||
|
</div> -->
|
||||||
|
<div class="row q-mt-md">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
:disable="formDialog.data.wallet == null || formDialog.data.name == null"
|
||||||
|
type="submit"
|
||||||
|
>Create Mint
|
||||||
|
</q-btn>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||||
|
>Cancel</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script>
|
||||||
|
var mapMint = function (obj) {
|
||||||
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
|
new Date(obj.time * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
)
|
||||||
|
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||||
|
obj.cashu = ['/cashu/', obj.id].join('')
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
cashus: [],
|
||||||
|
hostname: location.protocol + '//' + location.host + '/cashu/mint/',
|
||||||
|
toggleAdvanced: false,
|
||||||
|
cashusTable: {
|
||||||
|
columns: [
|
||||||
|
{name: 'id', align: 'left', label: 'Mint ID', field: 'id'},
|
||||||
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
|
// {
|
||||||
|
// name: 'tickershort',
|
||||||
|
// align: 'left',
|
||||||
|
// label: 'Ticker',
|
||||||
|
// field: 'tickershort'
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
name: 'wallet',
|
||||||
|
align: 'left',
|
||||||
|
label: 'Mint wallet',
|
||||||
|
field: 'wallet'
|
||||||
|
}
|
||||||
|
// {
|
||||||
|
// name: 'fraction',
|
||||||
|
// align: 'left',
|
||||||
|
// label: 'Using fraction',
|
||||||
|
// field: 'fraction'
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'maxsats',
|
||||||
|
// align: 'left',
|
||||||
|
// label: 'Max Sats',
|
||||||
|
// field: 'maxsats'
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'coins',
|
||||||
|
// align: 'left',
|
||||||
|
// label: 'No. of coins',
|
||||||
|
// field: 'coins'
|
||||||
|
// }
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {fraction: false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
closeFormDialog: function () {
|
||||||
|
this.formDialog.data = {}
|
||||||
|
},
|
||||||
|
getMints: function () {
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/cashu/api/v1/mints?all_wallets=true',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.cashus = response.data.map(function (obj) {
|
||||||
|
return mapMint(obj)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
createMint: function () {
|
||||||
|
if (this.formDialog.data.maxliquid == null) {
|
||||||
|
this.formDialog.data.maxliquid = 0
|
||||||
|
}
|
||||||
|
var data = {
|
||||||
|
name: this.formDialog.data.name,
|
||||||
|
tickershort: this.formDialog.data.tickershort,
|
||||||
|
maxliquid: this.formDialog.data.maxliquid
|
||||||
|
}
|
||||||
|
var self = this
|
||||||
|
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'POST',
|
||||||
|
'/cashu/api/v1/mints',
|
||||||
|
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||||
|
.inkey,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.cashus.push(mapMint(response.data))
|
||||||
|
self.formDialog.show = false
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteMint: function (cashuId) {
|
||||||
|
var self = this
|
||||||
|
var cashu = _.findWhere(this.cashus, {id: cashuId})
|
||||||
|
console.log(cashu)
|
||||||
|
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(
|
||||||
|
"Are you sure you want to delete this Mint? This mint's users will not be able to redeem their tokens!"
|
||||||
|
)
|
||||||
|
.onOk(function () {
|
||||||
|
LNbits.api
|
||||||
|
.request(
|
||||||
|
'DELETE',
|
||||||
|
'/cashu/api/v1/mints/' + cashuId,
|
||||||
|
_.findWhere(self.g.user.wallets, {id: cashu.wallet}).adminkey
|
||||||
|
)
|
||||||
|
.then(function (response) {
|
||||||
|
self.cashus = _.reject(self.cashus, function (obj) {
|
||||||
|
return obj.id == cashuId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
exportCSV: function () {
|
||||||
|
LNbits.utils.exportCSV(this.cashusTable.columns, this.cashus)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created: function () {
|
||||||
|
if (this.g.user.wallets.length) {
|
||||||
|
this.getMints()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
76
lnbits/extensions/cashu/templates/cashu/mint.html
Normal file
76
lnbits/extensions/cashu/templates/cashu/mint.html
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
{% extends "public.html" %} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md justify-center">
|
||||||
|
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||||
|
<q-card class="q-pa-lg q-mb-xl">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<center>
|
||||||
|
<q-icon
|
||||||
|
name="account_balance"
|
||||||
|
class="text-grey"
|
||||||
|
style="font-size: 10rem"
|
||||||
|
></q-icon>
|
||||||
|
<h4 class="q-mt-none q-mb-md">{{ mint_name }}</h4>
|
||||||
|
<a
|
||||||
|
class="q-my-xl text-white"
|
||||||
|
style="font-size: 1.5rem"
|
||||||
|
href="../wallet?mint_id={{ mint_id }}"
|
||||||
|
>Open wallet</a
|
||||||
|
>
|
||||||
|
</center>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
<q-card class="q-pa-lg q-mb-xl">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<h5 class="q-my-md">Read the following carefully!</h5>
|
||||||
|
<p>
|
||||||
|
This is a
|
||||||
|
<a href="https://cashu.space/" style="color: white" target="”_blank”"
|
||||||
|
>Cashu</a
|
||||||
|
>
|
||||||
|
mint. Cashu is an ecash system for Bitcoin.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Open this page in your native browser</strong><br />
|
||||||
|
Before you continue to the wallet, make sure to open this page in your
|
||||||
|
device's native browser application (Safari for iOS, Chrome for
|
||||||
|
Android). Do not use Cashu in an embedded browser that opens when you
|
||||||
|
click a link in a messenger.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Add wallet to home screen</strong><br />
|
||||||
|
You can add Cashu to your home screen as a progressive web app (PWA).
|
||||||
|
After opening the wallet in your browser (click the link above), on
|
||||||
|
Android (Chrome), click the menu at the upper right. On iOS (Safari),
|
||||||
|
click the share button. Now press the Add to Home screen button.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Backup your wallet</strong><br />
|
||||||
|
Ecash is a bearer asset. That means losing access to your wallet will
|
||||||
|
make you lose your funds. The wallet stores ecash tokens on your
|
||||||
|
device's database. If you lose the link or delete your your data
|
||||||
|
without backing up, you will lose your tokens. Press the Backup button
|
||||||
|
in the wallet to download a copy of your tokens.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>This service is in BETA</strong> <br />
|
||||||
|
We hold no responsibility for people losing access to funds. Use at
|
||||||
|
your own risk!
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
data: function () {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
2343
lnbits/extensions/cashu/templates/cashu/wallet.html
Normal file
2343
lnbits/extensions/cashu/templates/cashu/wallet.html
Normal file
File diff suppressed because it is too large
Load diff
230
lnbits/extensions/cashu/views.py
Normal file
230
lnbits/extensions/cashu/views.py
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
|
||||||
|
from . import cashu_ext, cashu_renderer
|
||||||
|
from .crud import get_cashu
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(check_user_exists), # type: ignore
|
||||||
|
):
|
||||||
|
return cashu_renderer().TemplateResponse(
|
||||||
|
"cashu/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.get("/mint/{mintID}")
|
||||||
|
async def cashu(request: Request, mintID):
|
||||||
|
cashu = await get_cashu(mintID)
|
||||||
|
if not cashu:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
return cashu_renderer().TemplateResponse(
|
||||||
|
"cashu/mint.html",
|
||||||
|
{"request": request, "mint_name": cashu.name, "mint_id": mintID},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.get("/manifest/{cashu_id}.webmanifest")
|
||||||
|
async def manifest(cashu_id: str):
|
||||||
|
cashu = await get_cashu(cashu_id)
|
||||||
|
if not cashu:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"short_name": "Cashu",
|
||||||
|
"name": "Cashu" + " - " + cashu.name,
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-512-512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-96-96.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"id": "/cashu/wallet?mint_id=" + cashu_id,
|
||||||
|
"start_url": "/cashu/wallet?mint_id=" + cashu_id,
|
||||||
|
"background_color": "#1F2234",
|
||||||
|
"description": "Cashu ecash wallet",
|
||||||
|
"display": "standalone",
|
||||||
|
"scope": "/cashu/",
|
||||||
|
"theme_color": "#1F2234",
|
||||||
|
"protocol_handlers": [
|
||||||
|
{"protocol": "cashu", "url": "&recv_token=%s"},
|
||||||
|
{"protocol": "lightning", "url": "&lightning=%s"},
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Cashu" + " - " + cashu.name,
|
||||||
|
"short_name": "Cashu",
|
||||||
|
"description": "Cashu" + " - " + cashu.name,
|
||||||
|
"url": "/cashu/wallet?mint_id=" + cashu_id,
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-512-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-192-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-144-144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-96-96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-72-72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-48-48.png",
|
||||||
|
"sizes": "48x48",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/16.png",
|
||||||
|
"sizes": "16x16",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/20.png",
|
||||||
|
"sizes": "20x20",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/29.png",
|
||||||
|
"sizes": "29x29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/32.png",
|
||||||
|
"sizes": "32x32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/40.png",
|
||||||
|
"sizes": "40x40",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/50.png",
|
||||||
|
"sizes": "50x50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/57.png",
|
||||||
|
"sizes": "57x57",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/58.png",
|
||||||
|
"sizes": "58x58",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/60.png",
|
||||||
|
"sizes": "60x60",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/64.png",
|
||||||
|
"sizes": "64x64",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/76.png",
|
||||||
|
"sizes": "76x76",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/80.png",
|
||||||
|
"sizes": "80x80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/87.png",
|
||||||
|
"sizes": "87x87",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/100.png",
|
||||||
|
"sizes": "100x100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/114.png",
|
||||||
|
"sizes": "114x114",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/120.png",
|
||||||
|
"sizes": "120x120",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/167.png",
|
||||||
|
"sizes": "167x167",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/180.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/256.png",
|
||||||
|
"sizes": "256x256",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/1024.png",
|
||||||
|
"sizes": "1024x1024",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
396
lnbits/extensions/cashu/views_api.py
Normal file
396
lnbits/extensions/cashu/views_api.py
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from http import HTTPStatus
|
||||||
|
from typing import Dict, List, Union
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
# -------- cashu imports
|
||||||
|
from cashu.core.base import (
|
||||||
|
BlindedSignature,
|
||||||
|
CheckFeesRequest,
|
||||||
|
CheckFeesResponse,
|
||||||
|
CheckRequest,
|
||||||
|
GetMeltResponse,
|
||||||
|
GetMintResponse,
|
||||||
|
Invoice,
|
||||||
|
MeltRequest,
|
||||||
|
MintRequest,
|
||||||
|
PostSplitResponse,
|
||||||
|
Proof,
|
||||||
|
SplitRequest,
|
||||||
|
)
|
||||||
|
from fastapi import Query
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from lnurl import decode as decode_lnurl
|
||||||
|
from loguru import logger
|
||||||
|
from secp256k1 import PublicKey
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits import bolt11
|
||||||
|
from lnbits.core.crud import check_internal, get_user
|
||||||
|
from lnbits.core.services import (
|
||||||
|
check_transaction_status,
|
||||||
|
create_invoice,
|
||||||
|
fee_reserve,
|
||||||
|
pay_invoice,
|
||||||
|
)
|
||||||
|
from lnbits.core.views.api import api_payment
|
||||||
|
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from lnbits.wallets.base import PaymentStatus
|
||||||
|
|
||||||
|
from . import cashu_ext, ledger
|
||||||
|
from .crud import create_cashu, delete_cashu, get_cashu, get_cashus
|
||||||
|
from .models import Cashu
|
||||||
|
|
||||||
|
# --------- extension imports
|
||||||
|
|
||||||
|
|
||||||
|
LIGHTNING = True
|
||||||
|
|
||||||
|
########################################
|
||||||
|
############### LNBITS MINTS ###########
|
||||||
|
########################################
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.get("/api/v1/mints", status_code=HTTPStatus.OK)
|
||||||
|
async def api_cashus(
|
||||||
|
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all mints of this wallet.
|
||||||
|
"""
|
||||||
|
wallet_ids = [wallet.wallet.id]
|
||||||
|
if all_wallets:
|
||||||
|
user = await get_user(wallet.wallet.user)
|
||||||
|
if user:
|
||||||
|
wallet_ids = user.wallet_ids
|
||||||
|
|
||||||
|
return [cashu.dict() for cashu in await get_cashus(wallet_ids)]
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.post("/api/v1/mints", status_code=HTTPStatus.CREATED)
|
||||||
|
async def api_cashu_create(
|
||||||
|
data: Cashu,
|
||||||
|
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new mint for this wallet.
|
||||||
|
"""
|
||||||
|
cashu_id = urlsafe_short_hash()
|
||||||
|
# generate a new keyset in cashu
|
||||||
|
keyset = await ledger.load_keyset(cashu_id)
|
||||||
|
|
||||||
|
cashu = await create_cashu(
|
||||||
|
cashu_id=cashu_id, keyset_id=keyset.id, wallet_id=wallet.wallet.id, data=data
|
||||||
|
)
|
||||||
|
logger.debug(cashu)
|
||||||
|
return cashu.dict()
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.delete("/api/v1/mints/{cashu_id}")
|
||||||
|
async def api_cashu_delete(
|
||||||
|
cashu_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) # type: ignore
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete an existing cashu mint.
|
||||||
|
"""
|
||||||
|
cashu = await get_cashu(cashu_id)
|
||||||
|
|
||||||
|
if not cashu:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Cashu mint does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
if cashu.wallet != wallet.wallet.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN, detail="Not your Cashu mint."
|
||||||
|
)
|
||||||
|
|
||||||
|
await delete_cashu(cashu_id)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
#######################################
|
||||||
|
########### CASHU ENDPOINTS ###########
|
||||||
|
#######################################
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.get("/api/v1/{cashu_id}/keys", status_code=HTTPStatus.OK)
|
||||||
|
async def keys(cashu_id: str = Query(None)) -> dict[int, str]:
|
||||||
|
"""Get the public keys of the mint"""
|
||||||
|
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
|
||||||
|
|
||||||
|
if not cashu:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
return ledger.get_keyset(keyset_id=cashu.keyset_id)
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.get("/api/v1/{cashu_id}/keysets", status_code=HTTPStatus.OK)
|
||||||
|
async def keysets(cashu_id: str = Query(None)) -> dict[str, list[str]]:
|
||||||
|
"""Get the public keys of the mint"""
|
||||||
|
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
|
||||||
|
|
||||||
|
if not cashu:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"keysets": [cashu.keyset_id]}
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.get("/api/v1/{cashu_id}/mint")
|
||||||
|
async def request_mint(cashu_id: str = Query(None), amount: int = 0) -> GetMintResponse:
|
||||||
|
"""
|
||||||
|
Request minting of new tokens. The mint responds with a Lightning invoice.
|
||||||
|
This endpoint can be used for a Lightning invoice UX flow.
|
||||||
|
|
||||||
|
Call `POST /mint` after paying the invoice.
|
||||||
|
"""
|
||||||
|
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
|
||||||
|
|
||||||
|
if not cashu:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
# create an invoice that the wallet needs to pay
|
||||||
|
try:
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=cashu.wallet,
|
||||||
|
amount=amount,
|
||||||
|
memo=f"{cashu.name}",
|
||||||
|
extra={"tag": "cashu"},
|
||||||
|
)
|
||||||
|
invoice = Invoice(
|
||||||
|
amount=amount, pr=payment_request, hash=payment_hash, issued=False
|
||||||
|
)
|
||||||
|
# await store_lightning_invoice(cashu_id, invoice)
|
||||||
|
await ledger.crud.store_lightning_invoice(invoice=invoice, db=ledger.db)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
print(f"Lightning invoice: {payment_request}")
|
||||||
|
resp = GetMintResponse(pr=payment_request, hash=payment_hash)
|
||||||
|
# return {"pr": payment_request, "hash": payment_hash}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.post("/api/v1/{cashu_id}/mint")
|
||||||
|
async def mint_coins(
|
||||||
|
data: MintRequest,
|
||||||
|
cashu_id: str = Query(None),
|
||||||
|
payment_hash: str = Query(None),
|
||||||
|
) -> List[BlindedSignature]:
|
||||||
|
"""
|
||||||
|
Requests the minting of tokens belonging to a paid payment request.
|
||||||
|
Call this endpoint after `GET /mint`.
|
||||||
|
"""
|
||||||
|
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
|
||||||
|
if cashu is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
|
||||||
|
if LIGHTNING:
|
||||||
|
invoice: Invoice = await ledger.crud.get_lightning_invoice(
|
||||||
|
db=ledger.db, hash=payment_hash
|
||||||
|
)
|
||||||
|
if invoice is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND,
|
||||||
|
detail="Mint does not know this invoice.",
|
||||||
|
)
|
||||||
|
if invoice.issued == True:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.PAYMENT_REQUIRED,
|
||||||
|
detail="Tokens already issued for this invoice.",
|
||||||
|
)
|
||||||
|
|
||||||
|
total_requested = sum([bm.amount for bm in data.blinded_messages])
|
||||||
|
if total_requested > invoice.amount:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.PAYMENT_REQUIRED,
|
||||||
|
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
|
||||||
|
)
|
||||||
|
|
||||||
|
status: PaymentStatus = await check_transaction_status(cashu.wallet, payment_hash)
|
||||||
|
|
||||||
|
if LIGHTNING and status.paid != True:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
keyset = ledger.keysets.keysets[cashu.keyset_id]
|
||||||
|
|
||||||
|
promises = await ledger._generate_promises(
|
||||||
|
B_s=data.blinded_messages, keyset=keyset
|
||||||
|
)
|
||||||
|
assert len(promises), HTTPException(
|
||||||
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="No promises returned."
|
||||||
|
)
|
||||||
|
await ledger.crud.update_lightning_invoice(
|
||||||
|
db=ledger.db, hash=payment_hash, issued=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return promises
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
|
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.post("/api/v1/{cashu_id}/melt")
|
||||||
|
async def melt_coins(
|
||||||
|
payload: MeltRequest, cashu_id: str = Query(None)
|
||||||
|
) -> GetMeltResponse:
|
||||||
|
"""Invalidates proofs and pays a Lightning invoice."""
|
||||||
|
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
|
||||||
|
if cashu is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
proofs = payload.proofs
|
||||||
|
invoice = payload.invoice
|
||||||
|
|
||||||
|
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
|
||||||
|
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
|
||||||
|
# TOKENS
|
||||||
|
assert all([p.id == cashu.keyset_id for p in proofs]), HTTPException(
|
||||||
|
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
|
||||||
|
detail="Error: Tokens are from another mint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# set proofs as pending
|
||||||
|
await ledger._set_proofs_pending(proofs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ledger._verify_proofs(proofs)
|
||||||
|
|
||||||
|
total_provided = sum([p["amount"] for p in proofs])
|
||||||
|
invoice_obj = bolt11.decode(invoice)
|
||||||
|
amount = math.ceil(invoice_obj.amount_msat / 1000)
|
||||||
|
|
||||||
|
internal_checking_id = await check_internal(invoice_obj.payment_hash)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return GetMeltResponse(paid=status.paid, preimage=status.preimage)
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.post("/api/v1/{cashu_id}/check")
|
||||||
|
async def check_spendable(
|
||||||
|
payload: CheckRequest, cashu_id: str = Query(None)
|
||||||
|
) -> Dict[int, bool]:
|
||||||
|
"""Check whether a secret has been spent already or not."""
|
||||||
|
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
|
||||||
|
if cashu is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
return await ledger.check_spendable(payload.proofs)
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.post("/api/v1/{cashu_id}/checkfees")
|
||||||
|
async def check_fees(
|
||||||
|
payload: CheckFeesRequest, cashu_id: str = Query(None)
|
||||||
|
) -> CheckFeesResponse:
|
||||||
|
"""
|
||||||
|
Responds with the fees necessary to pay a Lightning invoice.
|
||||||
|
Used by wallets for figuring out the fees they need to supply.
|
||||||
|
This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu).
|
||||||
|
"""
|
||||||
|
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
|
||||||
|
if cashu is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
invoice_obj = bolt11.decode(payload.pr)
|
||||||
|
internal_checking_id = await check_internal(invoice_obj.payment_hash)
|
||||||
|
|
||||||
|
if not internal_checking_id:
|
||||||
|
fees_msat = fee_reserve(invoice_obj.amount_msat)
|
||||||
|
else:
|
||||||
|
fees_msat = 0
|
||||||
|
return CheckFeesResponse(fee=math.ceil(fees_msat / 1000))
|
||||||
|
|
||||||
|
|
||||||
|
@cashu_ext.post("/api/v1/{cashu_id}/split")
|
||||||
|
async def split(
|
||||||
|
payload: SplitRequest, cashu_id: str = Query(None)
|
||||||
|
) -> PostSplitResponse:
|
||||||
|
"""
|
||||||
|
Requetst a set of tokens with amount "total" to be split into two
|
||||||
|
newly minted sets with amount "split" and "total-split".
|
||||||
|
"""
|
||||||
|
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
|
||||||
|
if cashu is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||||
|
)
|
||||||
|
proofs = payload.proofs
|
||||||
|
|
||||||
|
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
|
||||||
|
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
|
||||||
|
# TOKENS
|
||||||
|
if not all([p.id == cashu.keyset_id for p in proofs]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
|
||||||
|
detail="Error: Tokens are from another mint.",
|
||||||
|
)
|
||||||
|
|
||||||
|
amount = payload.amount
|
||||||
|
outputs = payload.outputs.blinded_messages
|
||||||
|
assert outputs, Exception("no outputs provided.")
|
||||||
|
split_return = None
|
||||||
|
try:
|
||||||
|
keyset = ledger.keysets.keysets[cashu.keyset_id]
|
||||||
|
split_return = await ledger.split(proofs, amount, outputs, keyset)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
if not split_return:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
|
detail="there was an error with the split",
|
||||||
|
)
|
||||||
|
frst_promises, scnd_promises = split_return
|
||||||
|
resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises)
|
||||||
|
return resp
|
||||||
|
|
@ -7,11 +7,11 @@ from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
from lnbits.core import db as core_db
|
from lnbits.core import db as core_db
|
||||||
from lnbits.core.models import Payment
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.core.services import websocketUpdater
|
||||||
from lnbits.helpers import get_current_extension_name
|
from lnbits.helpers import get_current_extension_name
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
|
||||||
from .crud import get_copilot
|
from .crud import get_copilot
|
||||||
from .views import updater
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices():
|
async def wait_for_paid_invoices():
|
||||||
|
|
@ -65,9 +65,11 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
await mark_webhook_sent(payment, -1)
|
await mark_webhook_sent(payment, -1)
|
||||||
if payment.extra.get("comment"):
|
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:
|
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@
|
||||||
document.domain +
|
document.domain +
|
||||||
':' +
|
':' +
|
||||||
location.port +
|
location.port +
|
||||||
'/copilot/ws/' +
|
'/api/v1/ws/' +
|
||||||
self.copilot.id
|
self.copilot.id
|
||||||
} else {
|
} else {
|
||||||
localUrl =
|
localUrl =
|
||||||
|
|
@ -246,7 +246,7 @@
|
||||||
document.domain +
|
document.domain +
|
||||||
':' +
|
':' +
|
||||||
location.port +
|
location.port +
|
||||||
'/copilot/ws/' +
|
'/api/v1/ws/' +
|
||||||
self.copilot.id
|
self.copilot.id
|
||||||
}
|
}
|
||||||
this.connection = new WebSocket(localUrl)
|
this.connection = new WebSocket(localUrl)
|
||||||
|
|
|
||||||
|
|
@ -35,48 +35,3 @@ async def panel(request: Request):
|
||||||
return copilot_renderer().TemplateResponse(
|
return copilot_renderer().TemplateResponse(
|
||||||
"copilot/panel.html", {"request": request}
|
"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 fastapi.params import Depends
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from lnbits.core.services import websocketUpdater
|
||||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||||
|
|
||||||
from . import copilot_ext
|
from . import copilot_ext
|
||||||
|
|
@ -16,7 +17,6 @@ from .crud import (
|
||||||
update_copilot,
|
update_copilot,
|
||||||
)
|
)
|
||||||
from .models import CreateCopilotData
|
from .models import CreateCopilotData
|
||||||
from .views import updater
|
|
||||||
|
|
||||||
#######################COPILOT##########################
|
#######################COPILOT##########################
|
||||||
|
|
||||||
|
|
@ -92,7 +92,7 @@ async def api_copilot_ws_relay(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
|
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await updater(copilot_id, data, comment)
|
await websocketUpdater(copilot_id, str(data) + "-" + str(comment))
|
||||||
except:
|
except:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ async def api_discordbot_users_delete(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||||
)
|
)
|
||||||
await delete_discordbot_user(user_id)
|
await delete_discordbot_user(user_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
# Activate Extension
|
# Activate Extension
|
||||||
|
|
@ -129,4 +129,4 @@ async def api_discordbot_wallets_delete(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||||
)
|
)
|
||||||
await delete_discordbot_wallet(wallet_id, get_wallet.user)
|
await delete_discordbot_wallet(wallet_id, get_wallet.user)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database
|
||||||
from lnbits.helpers import template_renderer
|
from lnbits.helpers import template_renderer
|
||||||
|
from lnbits.tasks import catch_everything_and_restart
|
||||||
|
|
||||||
db = Database("ext_events")
|
db = Database("ext_events")
|
||||||
|
|
||||||
|
|
@ -13,5 +16,11 @@ def events_renderer():
|
||||||
return template_renderer(["lnbits/extensions/events/templates"])
|
return template_renderer(["lnbits/extensions/events/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
from .tasks import wait_for_paid_invoices
|
||||||
from .views import * # noqa
|
from .views import * # noqa
|
||||||
from .views_api import * # noqa
|
from .views_api import * # noqa
|
||||||
|
|
||||||
|
|
||||||
|
def events_start():
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||||
|
|
|
||||||
39
lnbits/extensions/events/tasks.py
Normal file
39
lnbits/extensions/events/tasks.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits import bolt11
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.core.services import pay_invoice
|
||||||
|
from lnbits.extensions.events.models import CreateTicket
|
||||||
|
from lnbits.helpers import get_current_extension_name
|
||||||
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
|
||||||
|
from .views_api import api_ticket_send_ticket
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_paid_invoices():
|
||||||
|
invoice_queue = asyncio.Queue()
|
||||||
|
register_invoice_listener(invoice_queue, get_current_extension_name())
|
||||||
|
|
||||||
|
while True:
|
||||||
|
payment = await invoice_queue.get()
|
||||||
|
await on_invoice_paid(payment)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
# (avoid loops)
|
||||||
|
if (
|
||||||
|
"events" == payment.extra.get("tag")
|
||||||
|
and payment.extra.get("name")
|
||||||
|
and payment.extra.get("email")
|
||||||
|
):
|
||||||
|
CreateTicket.name = str(payment.extra.get("name"))
|
||||||
|
CreateTicket.email = str(payment.extra.get("email"))
|
||||||
|
await api_ticket_send_ticket(payment.memo, payment.payment_hash, CreateTicket)
|
||||||
|
return
|
||||||
|
|
@ -135,7 +135,14 @@
|
||||||
var self = this
|
var self = this
|
||||||
axios
|
axios
|
||||||
|
|
||||||
.get('/events/api/v1/tickets/' + '{{ event_id }}')
|
.get(
|
||||||
|
'/events/api/v1/tickets/' +
|
||||||
|
'{{ event_id }}' +
|
||||||
|
'/' +
|
||||||
|
self.formDialog.data.name +
|
||||||
|
'/' +
|
||||||
|
self.formDialog.data.email
|
||||||
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.paymentReq = response.data.payment_request
|
self.paymentReq = response.data.payment_request
|
||||||
self.paymentCheck = response.data.payment_hash
|
self.paymentCheck = response.data.payment_hash
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@
|
||||||
dense
|
dense
|
||||||
v-model.number="formDialog.data.price_per_ticket"
|
v-model.number="formDialog.data.price_per_ticket"
|
||||||
type="number"
|
type="number"
|
||||||
label="Price per ticket "
|
label="Sats per ticket "
|
||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi.param_functions import Query
|
from fastapi.param_functions import Query
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
|
from loguru import logger
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
@ -78,7 +79,7 @@ async def api_form_delete(event_id, wallet: WalletTypeInfo = Depends(get_key_typ
|
||||||
|
|
||||||
await delete_event(event_id)
|
await delete_event(event_id)
|
||||||
await delete_event_tickets(event_id)
|
await delete_event_tickets(event_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
#########Tickets##########
|
#########Tickets##########
|
||||||
|
|
@ -96,8 +97,8 @@ async def api_tickets(
|
||||||
return [ticket.dict() for ticket in await get_tickets(wallet_ids)]
|
return [ticket.dict() for ticket in await get_tickets(wallet_ids)]
|
||||||
|
|
||||||
|
|
||||||
@events_ext.get("/api/v1/tickets/{event_id}")
|
@events_ext.get("/api/v1/tickets/{event_id}/{name}/{email}")
|
||||||
async def api_ticket_make_ticket(event_id):
|
async def api_ticket_make_ticket(event_id, name, email):
|
||||||
event = await get_event(event_id)
|
event = await get_event(event_id)
|
||||||
if not event:
|
if not event:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -108,11 +109,10 @@ async def api_ticket_make_ticket(event_id):
|
||||||
wallet_id=event.wallet,
|
wallet_id=event.wallet,
|
||||||
amount=event.price_per_ticket,
|
amount=event.price_per_ticket,
|
||||||
memo=f"{event_id}",
|
memo=f"{event_id}",
|
||||||
extra={"tag": "events"},
|
extra={"tag": "events", "name": name, "email": email},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||||
|
|
||||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -156,7 +156,7 @@ async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_
|
||||||
)
|
)
|
||||||
|
|
||||||
await delete_ticket(ticket_id)
|
await delete_ticket(ticket_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
# Event Tickets
|
# Event Tickets
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,16 @@ from lnbits.tasks import catch_everything_and_restart
|
||||||
|
|
||||||
db = Database("ext_gerty")
|
db = Database("ext_gerty")
|
||||||
|
|
||||||
|
|
||||||
|
gerty_static_files = [
|
||||||
|
{
|
||||||
|
"path": "/gerty/static",
|
||||||
|
"app": StaticFiles(packages=[("lnbits", "extensions/gerty/static")]),
|
||||||
|
"name": "gerty_static",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
gerty_ext: APIRouter = APIRouter(prefix="/gerty", tags=["Gerty"])
|
gerty_ext: APIRouter = APIRouter(prefix="/gerty", tags=["Gerty"])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
|
import json
|
||||||
|
import time
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .models import Gerty
|
from .models import Gerty, Mempool, MempoolEndpoint
|
||||||
|
|
||||||
|
|
||||||
async def create_gerty(wallet_id: str, data: Gerty) -> Gerty:
|
async def create_gerty(wallet_id: str, data: Gerty) -> Gerty:
|
||||||
|
|
@ -13,28 +18,30 @@ async def create_gerty(wallet_id: str, data: Gerty) -> Gerty:
|
||||||
INSERT INTO gerty.gertys (
|
INSERT INTO gerty.gertys (
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
wallet,
|
|
||||||
utc_offset,
|
utc_offset,
|
||||||
type,
|
type,
|
||||||
|
wallet,
|
||||||
lnbits_wallets,
|
lnbits_wallets,
|
||||||
mempool_endpoint,
|
mempool_endpoint,
|
||||||
exchange,
|
exchange,
|
||||||
display_preferences,
|
display_preferences,
|
||||||
refresh_time
|
refresh_time,
|
||||||
|
urls
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
gerty_id,
|
gerty_id,
|
||||||
data.name,
|
data.name,
|
||||||
data.wallet,
|
|
||||||
data.utc_offset,
|
data.utc_offset,
|
||||||
data.type,
|
data.type,
|
||||||
|
wallet_id,
|
||||||
data.lnbits_wallets,
|
data.lnbits_wallets,
|
||||||
data.mempool_endpoint,
|
data.mempool_endpoint,
|
||||||
data.exchange,
|
data.exchange,
|
||||||
data.display_preferences,
|
data.display_preferences,
|
||||||
data.refresh_time,
|
data.refresh_time,
|
||||||
|
data.urls,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -70,3 +77,61 @@ async def get_gertys(wallet_ids: Union[str, List[str]]) -> List[Gerty]:
|
||||||
|
|
||||||
async def delete_gerty(gerty_id: str) -> None:
|
async def delete_gerty(gerty_id: str) -> None:
|
||||||
await db.execute("DELETE FROM gerty.gertys WHERE id = ?", (gerty_id,))
|
await db.execute("DELETE FROM gerty.gertys WHERE id = ?", (gerty_id,))
|
||||||
|
|
||||||
|
|
||||||
|
#############MEMPOOL###########
|
||||||
|
|
||||||
|
|
||||||
|
async def get_mempool_info(endPoint: str, gerty) -> Optional[Mempool]:
|
||||||
|
logger.debug(endPoint)
|
||||||
|
endpoints = MempoolEndpoint()
|
||||||
|
url = ""
|
||||||
|
for endpoint in endpoints:
|
||||||
|
if endPoint == endpoint[0]:
|
||||||
|
url = endpoint[1]
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM gerty.mempool WHERE endpoint = ? AND mempool_endpoint = ?",
|
||||||
|
(
|
||||||
|
endPoint,
|
||||||
|
gerty.mempool_endpoint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(gerty.mempool_endpoint + url)
|
||||||
|
logger.debug(gerty.mempool_endpoint + url)
|
||||||
|
mempool_id = urlsafe_short_hash()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO gerty.mempool (
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
endpoint,
|
||||||
|
time,
|
||||||
|
mempool_endpoint
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
mempool_id,
|
||||||
|
json.dumps(response.json()),
|
||||||
|
endPoint,
|
||||||
|
int(time.time()),
|
||||||
|
gerty.mempool_endpoint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
if int(time.time()) - row.time > 20:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(gerty.mempool_endpoint + url)
|
||||||
|
await db.execute(
|
||||||
|
"UPDATE gerty.mempool SET data = ?, time = ? WHERE endpoint = ? AND mempool_endpoint = ?",
|
||||||
|
(
|
||||||
|
json.dumps(response.json()),
|
||||||
|
int(time.time()),
|
||||||
|
endPoint,
|
||||||
|
gerty.mempool_endpoint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
return json.loads(row.data)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
import textwrap
|
import textwrap
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||||
|
from lnbits.settings import settings
|
||||||
|
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
||||||
|
|
||||||
|
from .crud import get_gerty, get_mempool_info
|
||||||
from .number_prefixer import *
|
from .number_prefixer import *
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -13,7 +21,13 @@ def get_percent_difference(current, previous, precision=3):
|
||||||
|
|
||||||
|
|
||||||
# A helper function get a nicely formated dict for the text
|
# A helper function get a nicely formated dict for the text
|
||||||
def get_text_item_dict(text: str, font_size: int, x_pos: int = None, y_pos: int = None, gerty_type: str = 'Gerty'):
|
def get_text_item_dict(
|
||||||
|
text: str,
|
||||||
|
font_size: int,
|
||||||
|
x_pos: int = None,
|
||||||
|
y_pos: int = None,
|
||||||
|
gerty_type: str = "Gerty",
|
||||||
|
):
|
||||||
# Get line size by font size
|
# Get line size by font size
|
||||||
line_width = 20
|
line_width = 20
|
||||||
if font_size <= 12:
|
if font_size <= 12:
|
||||||
|
|
@ -26,7 +40,7 @@ def get_text_item_dict(text: str, font_size: int, x_pos: int = None, y_pos: int
|
||||||
line_width = 25
|
line_width = 25
|
||||||
|
|
||||||
# Get font sizes for Gerty mini
|
# Get font sizes for Gerty mini
|
||||||
if(gerty_type.lower() == 'mini gerty'):
|
if gerty_type.lower() == "mini gerty":
|
||||||
if font_size <= 12:
|
if font_size <= 12:
|
||||||
font_size = 1
|
font_size = 1
|
||||||
if font_size <= 15:
|
if font_size <= 15:
|
||||||
|
|
@ -38,8 +52,6 @@ def get_text_item_dict(text: str, font_size: int, x_pos: int = None, y_pos: int
|
||||||
else:
|
else:
|
||||||
font_size = 5
|
font_size = 5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# wrap the text
|
# wrap the text
|
||||||
wrapper = textwrap.TextWrapper(width=line_width)
|
wrapper = textwrap.TextWrapper(width=line_width)
|
||||||
word_list = wrapper.wrap(text=text)
|
word_list = wrapper.wrap(text=text)
|
||||||
|
|
@ -65,28 +77,26 @@ def format_number(number, precision=None):
|
||||||
return "{:,}".format(round(number, precision))
|
return "{:,}".format(round(number, precision))
|
||||||
|
|
||||||
|
|
||||||
async def get_mempool_recommended_fees(gerty):
|
|
||||||
if isinstance(gerty.mempool_endpoint, str):
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/fees/recommended")
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_mining_dashboard(gerty):
|
async def get_mining_dashboard(gerty):
|
||||||
areas = []
|
areas = []
|
||||||
if isinstance(gerty.mempool_endpoint, str):
|
if isinstance(gerty.mempool_endpoint, str):
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
# current hashrate
|
# current hashrate
|
||||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/mining/hashrate/1w")
|
r = await get_mempool_info("hashrate_1w", gerty)
|
||||||
data = r.json()
|
data = r
|
||||||
hashrateNow = data["currentHashrate"]
|
hashrateNow = data["currentHashrate"]
|
||||||
hashrateOneWeekAgo = data["hashrates"][6]["avgHashrate"]
|
hashrateOneWeekAgo = data["hashrates"][6]["avgHashrate"]
|
||||||
|
|
||||||
text = []
|
text = []
|
||||||
text.append(get_text_item_dict(text="Current mining hashrate", font_size=12,gerty_type=gerty.type))
|
|
||||||
text.append(
|
text.append(
|
||||||
get_text_item_dict(
|
get_text_item_dict(
|
||||||
text="{0}hash".format(si_format(hashrateNow, 6, True, " ")), font_size=20,gerty_type=gerty.type
|
text="Current mining hashrate", font_size=12, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0}hash".format(si_format(hashrateNow, 6, True, " ")),
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
text.append(
|
text.append(
|
||||||
|
|
@ -94,112 +104,177 @@ async def get_mining_dashboard(gerty):
|
||||||
text="{0} vs 7 days ago".format(
|
text="{0} vs 7 days ago".format(
|
||||||
get_percent_difference(hashrateNow, hashrateOneWeekAgo, 3)
|
get_percent_difference(hashrateNow, hashrateOneWeekAgo, 3)
|
||||||
),
|
),
|
||||||
font_size=12,gerty_type=gerty.type
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
areas.append(text)
|
areas.append(text)
|
||||||
|
|
||||||
r = await client.get(
|
r = await get_mempool_info("difficulty_adjustment", gerty)
|
||||||
gerty.mempool_endpoint + "/api/v1/difficulty-adjustment"
|
|
||||||
)
|
|
||||||
|
|
||||||
# timeAvg
|
# timeAvg
|
||||||
text = []
|
text = []
|
||||||
progress = "{0}%".format(round(r.json()["progressPercent"], 2))
|
progress = "{0}%".format(round(r["progressPercent"], 2))
|
||||||
text.append(get_text_item_dict(text="Progress through current epoch", font_size=12,gerty_type=gerty.type))
|
text.append(
|
||||||
text.append(get_text_item_dict(text=progress, font_size=60,gerty_type=gerty.type))
|
get_text_item_dict(
|
||||||
|
text="Progress through current epoch",
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(text=progress, font_size=60, gerty_type=gerty.type)
|
||||||
|
)
|
||||||
areas.append(text)
|
areas.append(text)
|
||||||
|
|
||||||
# difficulty adjustment
|
# difficulty adjustment
|
||||||
text = []
|
text = []
|
||||||
stat = r.json()["remainingTime"]
|
stat = r["remainingTime"]
|
||||||
text.append(get_text_item_dict(text="Time to next difficulty adjustment", font_size=12,gerty_type=gerty.type))
|
text.append(
|
||||||
text.append(get_text_item_dict(text=get_time_remaining(stat / 1000, 3), font_size=12,gerty_type=gerty.type))
|
get_text_item_dict(
|
||||||
|
text="Time to next difficulty adjustment",
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=get_time_remaining(stat / 1000, 3),
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
areas.append(text)
|
areas.append(text)
|
||||||
|
|
||||||
# difficultyChange
|
# difficultyChange
|
||||||
text = []
|
text = []
|
||||||
difficultyChange = round(r.json()["difficultyChange"], 2)
|
difficultyChange = round(r["difficultyChange"], 2)
|
||||||
text.append(get_text_item_dict(text="Estimated difficulty change", font_size=12,gerty_type=gerty.type))
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Estimated difficulty change",
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
text.append(
|
text.append(
|
||||||
get_text_item_dict(
|
get_text_item_dict(
|
||||||
text="{0}{1}%".format(
|
text="{0}{1}%".format(
|
||||||
"+" if difficultyChange > 0 else "", round(difficultyChange, 2)
|
"+" if difficultyChange > 0 else "", round(difficultyChange, 2)
|
||||||
),
|
),
|
||||||
font_size=60,gerty_type=gerty.type
|
font_size=60,
|
||||||
|
gerty_type=gerty.type,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
areas.append(text)
|
areas.append(text)
|
||||||
|
|
||||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/mining/hashrate/1m")
|
r = await get_mempool_info("hashrate_1m", gerty)
|
||||||
data = r.json()
|
data = r
|
||||||
stat = {}
|
stat = {}
|
||||||
stat["current"] = data["currentDifficulty"]
|
stat["current"] = data["currentDifficulty"]
|
||||||
stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2][
|
stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2]["difficulty"]
|
||||||
"difficulty"
|
|
||||||
]
|
|
||||||
return areas
|
return areas
|
||||||
|
|
||||||
|
|
||||||
async def api_get_lightning_stats(gerty):
|
|
||||||
stat = {}
|
|
||||||
if isinstance(gerty.mempool_endpoint, str):
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
r = await client.get(
|
|
||||||
gerty.mempool_endpoint + "/api/v1/lightning/statistics/latest"
|
|
||||||
)
|
|
||||||
data = r.json()
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
async def get_lightning_stats(gerty):
|
async def get_lightning_stats(gerty):
|
||||||
data = await api_get_lightning_stats(gerty)
|
data = await get_mempool_info("statistics", gerty)
|
||||||
areas = []
|
areas = []
|
||||||
|
|
||||||
text = []
|
text = []
|
||||||
text.append(get_text_item_dict(text="Channel Count", font_size=12, gerty_type=gerty.type))
|
text.append(
|
||||||
text.append(get_text_item_dict(text=format_number(data["latest"]["channel_count"]), font_size=20, gerty_type=gerty.type))
|
get_text_item_dict(text="Channel Count", font_size=12, gerty_type=gerty.type)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=format_number(data["latest"]["channel_count"]),
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
difference = get_percent_difference(
|
difference = get_percent_difference(
|
||||||
current=data["latest"]["channel_count"],
|
current=data["latest"]["channel_count"],
|
||||||
previous=data["previous"]["channel_count"],
|
previous=data["previous"]["channel_count"],
|
||||||
)
|
)
|
||||||
text.append(get_text_item_dict(text="{0} in last 7 days".format(difference), font_size=12, gerty_type=gerty.type))
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} in last 7 days".format(difference),
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
areas.append(text)
|
areas.append(text)
|
||||||
|
|
||||||
text = []
|
text = []
|
||||||
text.append(get_text_item_dict(text="Number of Nodes", font_size=12,gerty_type=gerty.type))
|
text.append(
|
||||||
text.append(get_text_item_dict(text=format_number(data["latest"]["node_count"]), font_size=20,gerty_type=gerty.type))
|
get_text_item_dict(text="Number of Nodes", font_size=12, gerty_type=gerty.type)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=format_number(data["latest"]["node_count"]),
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
difference = get_percent_difference(
|
difference = get_percent_difference(
|
||||||
current=data["latest"]["node_count"], previous=data["previous"]["node_count"]
|
current=data["latest"]["node_count"], previous=data["previous"]["node_count"]
|
||||||
)
|
)
|
||||||
text.append(get_text_item_dict(text="{0} in last 7 days".format(difference), font_size=12,gerty_type=gerty.type))
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} in last 7 days".format(difference),
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
areas.append(text)
|
areas.append(text)
|
||||||
|
|
||||||
text = []
|
text = []
|
||||||
text.append(get_text_item_dict(text="Total Capacity", font_size=12,gerty_type=gerty.type))
|
text.append(
|
||||||
|
get_text_item_dict(text="Total Capacity", font_size=12, gerty_type=gerty.type)
|
||||||
|
)
|
||||||
avg_capacity = float(data["latest"]["total_capacity"]) / float(100000000)
|
avg_capacity = float(data["latest"]["total_capacity"]) / float(100000000)
|
||||||
text.append(
|
text.append(
|
||||||
get_text_item_dict(text="{0} BTC".format(format_number(avg_capacity, 2)), font_size=20,gerty_type=gerty.type)
|
get_text_item_dict(
|
||||||
|
text="{0} BTC".format(format_number(avg_capacity, 2)),
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
difference = get_percent_difference(
|
difference = get_percent_difference(
|
||||||
current=data["latest"]["total_capacity"],
|
current=data["latest"]["total_capacity"],
|
||||||
previous=data["previous"]["total_capacity"],
|
previous=data["previous"]["total_capacity"],
|
||||||
)
|
)
|
||||||
text.append(get_text_item_dict(text="{0} in last 7 days".format(difference), font_size=12,gerty_type=gerty.type))
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} in last 7 days".format(difference),
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
areas.append(text)
|
areas.append(text)
|
||||||
|
|
||||||
text = []
|
text = []
|
||||||
text.append(get_text_item_dict(text="Average Channel Capacity", font_size=12,gerty_type=gerty.type))
|
|
||||||
text.append(
|
text.append(
|
||||||
get_text_item_dict(
|
get_text_item_dict(
|
||||||
text="{0} sats".format(format_number(data["latest"]["avg_capacity"])), font_size=20,gerty_type=gerty.type
|
text="Average Channel Capacity", font_size=12, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} sats".format(format_number(data["latest"]["avg_capacity"])),
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
difference = get_percent_difference(
|
difference = get_percent_difference(
|
||||||
current=data["latest"]["avg_capacity"],
|
current=data["latest"]["avg_capacity"],
|
||||||
previous=data["previous"]["avg_capacity"],
|
previous=data["previous"]["avg_capacity"],
|
||||||
)
|
)
|
||||||
text.append(get_text_item_dict(text="{0} in last 7 days".format(difference), font_size=12, gerty_type=gerty.type))
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} in last 7 days".format(difference),
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
areas.append(text)
|
areas.append(text)
|
||||||
|
|
||||||
return areas
|
return areas
|
||||||
|
|
@ -220,8 +295,6 @@ def gerty_should_sleep(utc_offset: int = 0):
|
||||||
local_time = utc_now + timedelta(hours=utc_offset)
|
local_time = utc_now + timedelta(hours=utc_offset)
|
||||||
hours = local_time.strftime("%H")
|
hours = local_time.strftime("%H")
|
||||||
hours = int(hours)
|
hours = int(hours)
|
||||||
logger.debug("HOURS")
|
|
||||||
logger.debug(hours)
|
|
||||||
if hours >= 22 and hours <= 23:
|
if hours >= 22 and hours <= 23:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
|
@ -260,36 +333,647 @@ async def get_mining_stat(stat_slug: str, gerty):
|
||||||
text = []
|
text = []
|
||||||
if stat_slug == "mining_current_hash_rate":
|
if stat_slug == "mining_current_hash_rate":
|
||||||
stat = await api_get_mining_stat(stat_slug, gerty)
|
stat = await api_get_mining_stat(stat_slug, gerty)
|
||||||
logger.debug(stat)
|
current = "{0}hash".format(si_format(stat["current"], 6, True, " "))
|
||||||
current = "{0}hash".format(si_format(stat['current'], 6, True, " "))
|
text.append(
|
||||||
text.append(get_text_item_dict(text="Current Mining Hashrate", font_size=20,gerty_type=gerty.type))
|
get_text_item_dict(
|
||||||
text.append(get_text_item_dict(text=current, font_size=40,gerty_type=gerty.type))
|
text="Current Mining Hashrate", font_size=20, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(text=current, font_size=40, gerty_type=gerty.type)
|
||||||
|
)
|
||||||
# compare vs previous time period
|
# compare vs previous time period
|
||||||
difference = get_percent_difference(current=stat['current'], previous=stat['1w'])
|
difference = get_percent_difference(
|
||||||
text.append(get_text_item_dict(text="{0} in last 7 days".format(difference), font_size=12,gerty_type=gerty.type))
|
current=stat["current"], previous=stat["1w"]
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} in last 7 days".format(difference),
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
elif stat_slug == "mining_current_difficulty":
|
elif stat_slug == "mining_current_difficulty":
|
||||||
stat = await api_get_mining_stat(stat_slug, gerty)
|
stat = await api_get_mining_stat(stat_slug, gerty)
|
||||||
text.append(get_text_item_dict(text="Current Mining Difficulty", font_size=20,gerty_type=gerty.type))
|
text.append(
|
||||||
text.append(get_text_item_dict(text=format_number(stat['current']), font_size=40,gerty_type=gerty.type))
|
get_text_item_dict(
|
||||||
difference = get_percent_difference(current=stat['current'], previous=stat['previous'])
|
text="Current Mining Difficulty", font_size=20, gerty_type=gerty.type
|
||||||
text.append(get_text_item_dict(text="{0} since last adjustment".format(difference), font_size=12,gerty_type=gerty.type))
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=format_number(stat["current"]), font_size=40, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
difference = get_percent_difference(
|
||||||
|
current=stat["current"], previous=stat["previous"]
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} since last adjustment".format(difference),
|
||||||
|
font_size=12,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
# text.append(get_text_item_dict("Required threshold for mining proof-of-work", 12))
|
# text.append(get_text_item_dict("Required threshold for mining proof-of-work", 12))
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
async def api_get_mining_stat(stat_slug: str, gerty):
|
async def api_get_mining_stat(stat_slug: str, gerty):
|
||||||
stat = ""
|
stat = ""
|
||||||
if stat_slug == "mining_current_hash_rate":
|
if stat_slug == "mining_current_hash_rate":
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/mining/hashrate/1m")
|
r = await get_mempool_info("hashrate_1m", gerty)
|
||||||
data = r.json()
|
data = r
|
||||||
stat = {}
|
stat = {}
|
||||||
stat['current'] = data['currentHashrate']
|
stat["current"] = data["currentHashrate"]
|
||||||
stat['1w'] = data['hashrates'][len(data['hashrates']) - 7]['avgHashrate']
|
stat["1w"] = data["hashrates"][len(data["hashrates"]) - 7]["avgHashrate"]
|
||||||
elif stat_slug == "mining_current_difficulty":
|
elif stat_slug == "mining_current_difficulty":
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/mining/hashrate/1m")
|
r = await get_mempool_info("hashrate_1m", gerty)
|
||||||
data = r.json()
|
data = r
|
||||||
stat = {}
|
stat = {}
|
||||||
stat['current'] = data['currentDifficulty']
|
stat["current"] = data["currentDifficulty"]
|
||||||
stat['previous'] = data['difficulty'][len(data['difficulty']) - 2]['difficulty']
|
stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2][
|
||||||
|
"difficulty"
|
||||||
|
]
|
||||||
return stat
|
return stat
|
||||||
|
|
||||||
|
|
||||||
|
###########################################
|
||||||
|
|
||||||
|
|
||||||
|
async def get_satoshi():
|
||||||
|
maxQuoteLength = 186
|
||||||
|
with open(
|
||||||
|
os.path.join(settings.lnbits_path, "extensions/gerty/static/satoshi.json")
|
||||||
|
) as fd:
|
||||||
|
satoshiQuotes = json.load(fd)
|
||||||
|
quote = satoshiQuotes[random.randint(0, len(satoshiQuotes) - 1)]
|
||||||
|
# logger.debug(quote.text)
|
||||||
|
if len(quote["text"]) > maxQuoteLength:
|
||||||
|
logger.debug("Quote is too long, getting another")
|
||||||
|
return await get_satoshi()
|
||||||
|
else:
|
||||||
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
# Get a screen slug by its position in the screens_list
|
||||||
|
def get_screen_slug_by_index(index: int, screens_list):
|
||||||
|
if index <= len(screens_list) - 1:
|
||||||
|
return list(screens_list)[index - 1]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Get a list of text items for the screen number
|
||||||
|
async def get_screen_data(screen_num: int, screens_list: dict, gerty):
|
||||||
|
screen_slug = get_screen_slug_by_index(screen_num, screens_list)
|
||||||
|
# first get the relevant slug from the display_preferences
|
||||||
|
areas = []
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
if screen_slug == "dashboard":
|
||||||
|
title = gerty.name
|
||||||
|
areas = await get_dashboard(gerty)
|
||||||
|
if screen_slug == "lnbits_wallets_balance":
|
||||||
|
wallets = await get_lnbits_wallet_balances(gerty)
|
||||||
|
|
||||||
|
for wallet in wallets:
|
||||||
|
text = []
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0}'s Wallet".format(wallet["name"]),
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} sats".format(format_number(wallet["balance"])),
|
||||||
|
font_size=40,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
elif screen_slug == "url_checker":
|
||||||
|
for url in json.loads(gerty.urls):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
text = []
|
||||||
|
try:
|
||||||
|
response = await client.get(url)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=url,
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=str(response.status_code),
|
||||||
|
font_size=40,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
text = []
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=url,
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=str("DOWN"),
|
||||||
|
font_size=40,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
elif screen_slug == "fun_satoshi_quotes":
|
||||||
|
areas.append(await get_satoshi_quotes(gerty))
|
||||||
|
elif screen_slug == "fun_exchange_market_rate":
|
||||||
|
areas.append(await get_exchange_rate(gerty))
|
||||||
|
elif screen_slug == "onchain_difficulty_epoch_progress":
|
||||||
|
areas.append(await get_onchain_stat(screen_slug, gerty))
|
||||||
|
elif screen_slug == "onchain_block_height":
|
||||||
|
text = []
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=format_number(await get_mempool_info("tip_height", gerty)),
|
||||||
|
font_size=80,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
elif screen_slug == "onchain_difficulty_retarget_date":
|
||||||
|
areas.append(await get_onchain_stat(screen_slug, gerty))
|
||||||
|
elif screen_slug == "onchain_difficulty_blocks_remaining":
|
||||||
|
areas.append(await get_onchain_stat(screen_slug, gerty))
|
||||||
|
elif screen_slug == "onchain_difficulty_epoch_time_remaining":
|
||||||
|
areas.append(await get_onchain_stat(screen_slug, gerty))
|
||||||
|
elif screen_slug == "dashboard_onchain":
|
||||||
|
title = "Onchain Data"
|
||||||
|
areas = await get_onchain_dashboard(gerty)
|
||||||
|
elif screen_slug == "mempool_recommended_fees":
|
||||||
|
areas.append(await get_mempool_stat(screen_slug, gerty))
|
||||||
|
elif screen_slug == "mempool_tx_count":
|
||||||
|
areas.append(await get_mempool_stat(screen_slug, gerty))
|
||||||
|
elif screen_slug == "mining_current_hash_rate":
|
||||||
|
areas.append(await get_mining_stat(screen_slug, gerty))
|
||||||
|
elif screen_slug == "mining_current_difficulty":
|
||||||
|
areas.append(await get_mining_stat(screen_slug, gerty))
|
||||||
|
elif screen_slug == "dashboard_mining":
|
||||||
|
title = "Mining Data"
|
||||||
|
areas = await get_mining_dashboard(gerty)
|
||||||
|
elif screen_slug == "lightning_dashboard":
|
||||||
|
title = "Lightning Network"
|
||||||
|
areas = await get_lightning_stats(gerty)
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
data["title"] = title
|
||||||
|
data["areas"] = areas
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# Get the dashboard screen
|
||||||
|
async def get_dashboard(gerty):
|
||||||
|
areas = []
|
||||||
|
# XC rate
|
||||||
|
text = []
|
||||||
|
amount = await satoshis_amount_as_fiat(100000000, gerty.exchange)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=format_number(amount), font_size=40, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="BTC{0} price".format(gerty.exchange),
|
||||||
|
font_size=15,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
# balance
|
||||||
|
text = []
|
||||||
|
wallets = await get_lnbits_wallet_balances(gerty)
|
||||||
|
text = []
|
||||||
|
for wallet in wallets:
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0}".format(wallet["name"]), font_size=15, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} sats".format(format_number(wallet["balance"])),
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
|
||||||
|
# Mempool fees
|
||||||
|
text = []
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=format_number(await get_mempool_info("tip_height", gerty)),
|
||||||
|
font_size=40,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Current block height", font_size=15, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
|
||||||
|
# difficulty adjustment time
|
||||||
|
text = []
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=await get_time_remaining_next_difficulty_adjustment(gerty),
|
||||||
|
font_size=15,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="until next difficulty adjustment", font_size=12, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
|
||||||
|
return areas
|
||||||
|
|
||||||
|
|
||||||
|
async def get_lnbits_wallet_balances(gerty):
|
||||||
|
# Get Wallet info
|
||||||
|
wallets = []
|
||||||
|
if gerty.lnbits_wallets != "":
|
||||||
|
for lnbits_wallet in json.loads(gerty.lnbits_wallets):
|
||||||
|
wallet = await get_wallet_for_key(key=lnbits_wallet)
|
||||||
|
if wallet:
|
||||||
|
wallets.append(
|
||||||
|
{
|
||||||
|
"name": wallet.name,
|
||||||
|
"balance": wallet.balance_msat / 1000,
|
||||||
|
"inkey": wallet.inkey,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return wallets
|
||||||
|
|
||||||
|
|
||||||
|
async def get_placeholder_text():
|
||||||
|
return [
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Some placeholder text",
|
||||||
|
x_pos=15,
|
||||||
|
y_pos=10,
|
||||||
|
font_size=50,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
),
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Some placeholder text",
|
||||||
|
x_pos=15,
|
||||||
|
y_pos=10,
|
||||||
|
font_size=50,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_satoshi_quotes(gerty):
|
||||||
|
# Get Satoshi quotes
|
||||||
|
text = []
|
||||||
|
quote = await get_satoshi()
|
||||||
|
if quote:
|
||||||
|
if quote["text"]:
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=quote["text"], font_size=15, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if quote["date"]:
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Satoshi Nakamoto - {0}".format(quote["date"]),
|
||||||
|
font_size=15,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# Get Exchange Value
|
||||||
|
async def get_exchange_rate(gerty):
|
||||||
|
text = []
|
||||||
|
if gerty.exchange != "":
|
||||||
|
try:
|
||||||
|
amount = await satoshis_amount_as_fiat(100000000, gerty.exchange)
|
||||||
|
if amount:
|
||||||
|
price = format_number(amount)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Current {0}/BTC price".format(gerty.exchange),
|
||||||
|
font_size=15,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(text=price, font_size=80, gerty_type=gerty.type)
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
async def get_onchain_stat(stat_slug: str, gerty):
|
||||||
|
text = []
|
||||||
|
if (
|
||||||
|
stat_slug == "onchain_difficulty_epoch_progress"
|
||||||
|
or stat_slug == "onchain_difficulty_retarget_date"
|
||||||
|
or stat_slug == "onchain_difficulty_blocks_remaining"
|
||||||
|
or stat_slug == "onchain_difficulty_epoch_time_remaining"
|
||||||
|
):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await get_mempool_info("difficulty_adjustment", gerty)
|
||||||
|
if stat_slug == "onchain_difficulty_epoch_progress":
|
||||||
|
stat = round(r["progressPercent"])
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Progress through current difficulty epoch",
|
||||||
|
font_size=15,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0}%".format(stat), font_size=80, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif stat_slug == "onchain_difficulty_retarget_date":
|
||||||
|
stat = r["estimatedRetargetDate"]
|
||||||
|
dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M")
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Date of next difficulty adjustment",
|
||||||
|
font_size=15,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(text=dt, font_size=40, gerty_type=gerty.type)
|
||||||
|
)
|
||||||
|
elif stat_slug == "onchain_difficulty_blocks_remaining":
|
||||||
|
stat = r["remainingBlocks"]
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Blocks until next difficulty adjustment",
|
||||||
|
font_size=15,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0}".format(format_number(stat)),
|
||||||
|
font_size=80,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif stat_slug == "onchain_difficulty_epoch_time_remaining":
|
||||||
|
stat = r["remainingTime"]
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Time until next difficulty adjustment",
|
||||||
|
font_size=15,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=get_time_remaining(stat / 1000, 4),
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
async def get_onchain_dashboard(gerty):
|
||||||
|
areas = []
|
||||||
|
if isinstance(gerty.mempool_endpoint, str):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await get_mempool_info("difficulty_adjustment", gerty)
|
||||||
|
text = []
|
||||||
|
stat = round(r["progressPercent"])
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Progress through epoch", font_size=12, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0}%".format(stat), font_size=60, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
|
||||||
|
text = []
|
||||||
|
stat = r["estimatedRetargetDate"]
|
||||||
|
dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M")
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Date of next adjustment", font_size=12, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(text=dt, font_size=20, gerty_type=gerty.type)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
|
||||||
|
text = []
|
||||||
|
stat = r["remainingBlocks"]
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Blocks until adjustment", font_size=12, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0}".format(format_number(stat)),
|
||||||
|
font_size=60,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
|
||||||
|
text = []
|
||||||
|
stat = r["remainingTime"]
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Time until adjustment", font_size=12, gerty_type=gerty.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text=get_time_remaining(stat / 1000, 4),
|
||||||
|
font_size=20,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
areas.append(text)
|
||||||
|
|
||||||
|
return areas
|
||||||
|
|
||||||
|
|
||||||
|
async def get_time_remaining_next_difficulty_adjustment(gerty):
|
||||||
|
if isinstance(gerty.mempool_endpoint, str):
|
||||||
|
r = await get_mempool_info("difficulty_adjustment", gerty)
|
||||||
|
stat = r["remainingTime"]
|
||||||
|
time = get_time_remaining(stat / 1000, 3)
|
||||||
|
return time
|
||||||
|
|
||||||
|
|
||||||
|
async def get_mempool_stat(stat_slug: str, gerty):
|
||||||
|
text = []
|
||||||
|
if isinstance(gerty.mempool_endpoint, str):
|
||||||
|
if stat_slug == "mempool_tx_count":
|
||||||
|
r = get_mempool_info("mempool", gerty)
|
||||||
|
if stat_slug == "mempool_tx_count":
|
||||||
|
stat = round(r["count"])
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="Transactions in the mempool",
|
||||||
|
font_size=15,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0}".format(format_number(stat)),
|
||||||
|
font_size=80,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif stat_slug == "mempool_recommended_fees":
|
||||||
|
y_offset = 60
|
||||||
|
fees = await get_mempool_info("fees_recommended", gerty)
|
||||||
|
pos_y = 80 + y_offset
|
||||||
|
text.append(get_text_item_dict("mempool.space", 40, 160, pos_y, gerty.type))
|
||||||
|
pos_y = 180 + y_offset
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict("Recommended Tx Fees", 20, 240, pos_y, gerty.type)
|
||||||
|
)
|
||||||
|
|
||||||
|
pos_y = 280 + y_offset
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict("{0}".format("None"), 15, 30, pos_y, gerty.type)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict("{0}".format("Low"), 15, 235, pos_y, gerty.type)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict("{0}".format("Medium"), 15, 460, pos_y, gerty.type)
|
||||||
|
)
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict("{0}".format("High"), 15, 750, pos_y, gerty.type)
|
||||||
|
)
|
||||||
|
|
||||||
|
pos_y = 340 + y_offset
|
||||||
|
font_size = 15
|
||||||
|
fee_append = "/vB"
|
||||||
|
fee_rate = fees["economyFee"]
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} {1}{2}".format(
|
||||||
|
format_number(fee_rate),
|
||||||
|
("sat" if fee_rate == 1 else "sats"),
|
||||||
|
fee_append,
|
||||||
|
),
|
||||||
|
font_size=font_size,
|
||||||
|
x_pos=30,
|
||||||
|
y_pos=pos_y,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fee_rate = fees["hourFee"]
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} {1}{2}".format(
|
||||||
|
format_number(fee_rate),
|
||||||
|
("sat" if fee_rate == 1 else "sats"),
|
||||||
|
fee_append,
|
||||||
|
),
|
||||||
|
font_size=font_size,
|
||||||
|
x_pos=235,
|
||||||
|
y_pos=pos_y,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fee_rate = fees["halfHourFee"]
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} {1}{2}".format(
|
||||||
|
format_number(fee_rate),
|
||||||
|
("sat" if fee_rate == 1 else "sats"),
|
||||||
|
fee_append,
|
||||||
|
),
|
||||||
|
font_size=font_size,
|
||||||
|
x_pos=460,
|
||||||
|
y_pos=pos_y,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fee_rate = fees["fastestFee"]
|
||||||
|
text.append(
|
||||||
|
get_text_item_dict(
|
||||||
|
text="{0} {1}{2}".format(
|
||||||
|
format_number(fee_rate),
|
||||||
|
("sat" if fee_rate == 1 else "sats"),
|
||||||
|
fee_append,
|
||||||
|
),
|
||||||
|
font_size=font_size,
|
||||||
|
x_pos=750,
|
||||||
|
y_pos=pos_y,
|
||||||
|
gerty_type=gerty.type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def get_date_suffix(dayNumber):
|
||||||
|
if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30:
|
||||||
|
return "th"
|
||||||
|
else:
|
||||||
|
return ["st", "nd", "rd"][dayNumber % 10 - 1]
|
||||||
|
|
||||||
|
|
||||||
|
def get_time_remaining(seconds, granularity=2):
|
||||||
|
intervals = (
|
||||||
|
# ('weeks', 604800), # 60 * 60 * 24 * 7
|
||||||
|
("days", 86400), # 60 * 60 * 24
|
||||||
|
("hours", 3600), # 60 * 60
|
||||||
|
("minutes", 60),
|
||||||
|
("seconds", 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for name, count in intervals:
|
||||||
|
value = seconds // count
|
||||||
|
if value:
|
||||||
|
seconds -= value * count
|
||||||
|
if value == 1:
|
||||||
|
name = name.rstrip("s")
|
||||||
|
result.append("{} {}".format(round(value), name))
|
||||||
|
return ", ".join(result[:granularity])
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ async def m001_initial(db):
|
||||||
"""
|
"""
|
||||||
CREATE TABLE gerty.gertys (
|
CREATE TABLE gerty.gertys (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
refresh_time INT,
|
refresh_time INT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
wallet TEXT NOT NULL,
|
|
||||||
lnbits_wallets TEXT,
|
lnbits_wallets TEXT,
|
||||||
mempool_endpoint TEXT,
|
mempool_endpoint TEXT,
|
||||||
exchange TEXT,
|
exchange TEXT,
|
||||||
|
|
@ -24,8 +24,36 @@ async def m002_add_utc_offset_col(db):
|
||||||
"""
|
"""
|
||||||
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN utc_offset INT;")
|
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN utc_offset INT;")
|
||||||
|
|
||||||
|
|
||||||
async def m003_add_gerty_model_col(db):
|
async def m003_add_gerty_model_col(db):
|
||||||
"""
|
"""
|
||||||
support for Gerty model col
|
support for Gerty model col
|
||||||
"""
|
"""
|
||||||
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN type TEXT;")
|
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN type TEXT;")
|
||||||
|
|
||||||
|
|
||||||
|
#########MEMPOOL MIGRATIONS########
|
||||||
|
|
||||||
|
|
||||||
|
async def m004_initial(db):
|
||||||
|
"""
|
||||||
|
Initial Gertys table.
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE gerty.mempool (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
mempool_endpoint TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
time TIMESTAMP
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m005_add_gerty_model_col(db):
|
||||||
|
"""
|
||||||
|
support for Gerty model col
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE gerty.gertys ADD COLUMN urls TEXT;")
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ from pydantic import BaseModel
|
||||||
class Gerty(BaseModel):
|
class Gerty(BaseModel):
|
||||||
id: str = Query(None)
|
id: str = Query(None)
|
||||||
name: str
|
name: str
|
||||||
wallet: str
|
|
||||||
refresh_time: int = Query(None)
|
refresh_time: int = Query(None)
|
||||||
utc_offset: int = Query(None)
|
utc_offset: int = Query(None)
|
||||||
|
wallet: str = Query(None)
|
||||||
type: str
|
type: str
|
||||||
lnbits_wallets: str = Query(
|
lnbits_wallets: str = Query(
|
||||||
None
|
None
|
||||||
|
|
@ -20,7 +20,29 @@ class Gerty(BaseModel):
|
||||||
None
|
None
|
||||||
) # BTC <-> Fiat exchange rate to pull ie "USD", in 0.0001 and sats
|
) # BTC <-> Fiat exchange rate to pull ie "USD", in 0.0001 and sats
|
||||||
display_preferences: str = Query(None)
|
display_preferences: str = Query(None)
|
||||||
|
urls: str = Query(None)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, row: Row) -> "Gerty":
|
def from_row(cls, row: Row) -> "Gerty":
|
||||||
return cls(**dict(row))
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
#########MEMPOOL MODELS###########
|
||||||
|
|
||||||
|
|
||||||
|
class MempoolEndpoint(BaseModel):
|
||||||
|
fees_recommended: str = "/api/v1/fees/recommended"
|
||||||
|
hashrate_1w: str = "/api/v1/mining/hashrate/1w"
|
||||||
|
hashrate_1m: str = "/api/v1/mining/hashrate/1m"
|
||||||
|
statistics: str = "/api/v1/lightning/statistics/latest"
|
||||||
|
difficulty_adjustment: str = "/api/v1/difficulty-adjustment"
|
||||||
|
tip_height: str = "/api/blocks/tip/height"
|
||||||
|
mempool: str = "/api/mempool"
|
||||||
|
|
||||||
|
|
||||||
|
class Mempool(BaseModel):
|
||||||
|
id: str = Query(None)
|
||||||
|
mempool_endpoint: str = Query(None)
|
||||||
|
endpoint: str = Query(None)
|
||||||
|
data: str = Query(None)
|
||||||
|
time: int = Query(None)
|
||||||
|
|
|
||||||
BIN
lnbits/extensions/gerty/static/gerty.jpg
Normal file
BIN
lnbits/extensions/gerty/static/gerty.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
|
|
@ -1,80 +1,17 @@
|
||||||
<q-expansion-item
|
<q-card-section>
|
||||||
group="extras"
|
<p>
|
||||||
icon="swap_vertical_circle"
|
Gerty (your bitcoin assistant): Use the software Gerty or
|
||||||
label="API info"
|
<a
|
||||||
:content-inset-level="0.5"
|
target="_blank"
|
||||||
>
|
href="https://shop.lnbits.com/product/gerty-a-bitcoin-assistant"
|
||||||
<q-btn flat label="Swagger API" type="a" href="../docs#/gerty"></q-btn>
|
>hardware Gerty</a
|
||||||
<q-expansion-item group="api" dense expand-separator label="List Gerty">
|
><br />
|
||||||
<q-card>
|
<small>
|
||||||
<q-card-section>
|
Created by, <a href="https://github.com/blackcoffeexbt">Black Coffee</a>,
|
||||||
<code><span class="text-blue">GET</span> /gerty/api/v1/gertys</code>
|
<a href="https://github.com/benarc">Ben Arc</a></small
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
|
||||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
|
||||||
Returns 200 OK (application/json)
|
|
||||||
</h5>
|
|
||||||
<code>[<gerty_object>, ...]</code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
|
||||||
<code
|
|
||||||
>curl -X GET {{ request.base_url }}gerty/api/v1/gertys -H "X-Api-Key:
|
|
||||||
<invoice_key>"
|
|
||||||
</code>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-expansion-item group="api" dense expand-separator label="Create a Gerty">
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<code><span class="text-green">POST</span> /gerty/api/v1/gertys</code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
|
||||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
|
||||||
<code
|
|
||||||
>{"name": <string>, "currency": <string*ie USD*>}</code
|
|
||||||
>
|
>
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
</p>
|
||||||
Returns 201 CREATED (application/json)
|
<a href="https://shop.lnbits.com/product/gerty-a-bitcoin-assistant"
|
||||||
</h5>
|
><img src="/gerty/static/gerty.jpg" style="max-width: 100%"
|
||||||
<code
|
/></a>
|
||||||
>{"currency": <string>, "id": <string>, "name":
|
</q-card-section>
|
||||||
<string>, "wallet": <string>}</code
|
|
||||||
>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
|
||||||
<code
|
|
||||||
>curl -X POST {{ request.base_url }}gerty/api/v1/gertys -d '{"name":
|
|
||||||
<string>, "currency": <string>}' -H "Content-type:
|
|
||||||
application/json" -H "X-Api-Key: <admin_key>"
|
|
||||||
</code>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
||||||
<q-expansion-item
|
|
||||||
group="api"
|
|
||||||
dense
|
|
||||||
expand-separator
|
|
||||||
label="Delete a Gerty"
|
|
||||||
class="q-pb-md"
|
|
||||||
>
|
|
||||||
<q-card>
|
|
||||||
<q-card-section>
|
|
||||||
<code
|
|
||||||
><span class="text-pink">DELETE</span>
|
|
||||||
/gerty/api/v1/gertys/<gerty_id></code
|
|
||||||
>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
|
||||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
|
||||||
<code></code>
|
|
||||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
|
||||||
<code
|
|
||||||
>curl -X DELETE {{ request.base_url
|
|
||||||
}}gerty/api/v1/gertys/<gerty_id> -H "X-Api-Key:
|
|
||||||
<admin_key>"
|
|
||||||
</code>
|
|
||||||
</q-card-section>
|
|
||||||
</q-card>
|
|
||||||
</q-expansion-item>
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,133 @@
|
||||||
{% extends "public.html" %} {% block toolbar_title %} {{ gerty.name }}{%
|
{% extends "public.html" %} {% block toolbar_title %} Gerty: {% raw %}{{
|
||||||
endblock %}{% block page %} {% raw %}
|
gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
|
||||||
<div class="q-pa-md row items-start q-gutter-md">
|
|
||||||
|
<div
|
||||||
|
class="q-pa-md row items-start q-gutter-md"
|
||||||
|
v-if="fun_exchange_market_rate || fun_satoshi_quotes"
|
||||||
|
>
|
||||||
<q-card
|
<q-card
|
||||||
|
v-if="fun_exchange_market_rate"
|
||||||
|
unelevated
|
||||||
|
class="q-pa-sm"
|
||||||
|
style="background: none !important"
|
||||||
|
>
|
||||||
|
<q-card-section class="text-h1 q-pa-none">
|
||||||
|
<small> <b>{{fun_exchange_market_rate["amount"]}}</b></small>
|
||||||
|
<small class="text-h4"
|
||||||
|
>{{fun_exchange_market_rate["unit"].split(" ")[1]}}</small
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card
|
||||||
|
v-if="fun_satoshi_quotes['quote']"
|
||||||
unelevated
|
unelevated
|
||||||
flat
|
|
||||||
class="q-pa-none text-body1 blockquote"
|
class="q-pa-none text-body1 blockquote"
|
||||||
style="background: none !important"
|
style="background: none !important"
|
||||||
>
|
>
|
||||||
"{{gerty.sats_quote[0].text}}" <br />~ Satoshi {{gerty.sats_quote[0].date}}
|
<blockquote class="text-right" style="max-width: 900px">
|
||||||
|
<p>"{{fun_satoshi_quotes["quote"]}}"</p>
|
||||||
|
<small>~ Satoshi {{fun_satoshi_quotes["date"]}}</small>
|
||||||
|
</blockquote>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
<div class="q-pa-md row items-start q-gutter-md">
|
|
||||||
<q-card unelevated flat class="q-pa-none" style="background: none !important">
|
<div class="q-pa-md row items-start q-gutter-md" v-if="lnbits_wallets_balance">
|
||||||
|
<q-card
|
||||||
|
class="q-pa-sm"
|
||||||
|
v-for="(wallet, t) in lnbits_wallets_balance"
|
||||||
|
:style="`background-color: ${wallet.color1} !important`"
|
||||||
|
unelevated
|
||||||
|
class="q-pa-none q-pa-sm"
|
||||||
|
>
|
||||||
<q-card-section class="text-h1 q-pa-none">
|
<q-card-section class="text-h1 q-pa-none">
|
||||||
{{gerty.exchange[0].amount.toFixed(2)}} {{gerty.exchange[0].fiat}}
|
<small> <b>{{wallet["amount"]}}</b></small>
|
||||||
</q-card-section>
|
<small class="text-h4">({{wallet["name"]}})</small>
|
||||||
</q-card>
|
|
||||||
<q-card v-for="gertywallet in gertywallets" style="width: 380px" flat>
|
|
||||||
<q-card-section
|
|
||||||
horizontal
|
|
||||||
class="q-pa-none"
|
|
||||||
:style="`background-color: ${gertywallet.color1}`"
|
|
||||||
>
|
|
||||||
<q-card-section class="q-pa-none">
|
|
||||||
<div
|
|
||||||
class="q-item__section column q-pa-lg q-mr-none text-white q-item__section--side justify-center"
|
|
||||||
:style="`background-color: ${gertywallet.color2}`"
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
aria-hidden="true"
|
|
||||||
role="presentation"
|
|
||||||
class="material-icons q-icon notranslate text-white"
|
|
||||||
style="font-size: 50px"
|
|
||||||
>sentiment_satisfied</i
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="q-item__section column q-pa-md q-ml-none text-white q-item__section--main justify-center"
|
|
||||||
style="min-width: 200px"
|
|
||||||
>
|
|
||||||
<div class="q-item__label text-white text-h6 text-weight-bolder">
|
|
||||||
{{gertywallet.amount}}
|
|
||||||
</div>
|
|
||||||
<div class="q-item__label"><b>{{gertywallet.name}}</b></div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row q-col-gutter-md">
|
<div
|
||||||
<div v-if="gerty.onchain[0]" class="col-12 col-sm-6 col-md-5 col-lg-6">
|
class="q-pa-md row items-start q-gutter-md"
|
||||||
<q-card class="q-pa-lg">
|
v-if="dashboard_onchain || dashboard_mining || lightning_dashboard"
|
||||||
<p class="text-h4">Onchain Stats</p>
|
>
|
||||||
Difficulty Progress Percent
|
<q-card
|
||||||
<q-linear-progress
|
class="q-pa-sm"
|
||||||
size="20px"
|
v-if="dashboard_onchain[0]"
|
||||||
:value="gerty.onchain[0].difficulty[0].progressPercent/100"
|
unelevated
|
||||||
color="primary"
|
class="q-pa-sm"
|
||||||
class="q-mt-sm"
|
|
||||||
>
|
>
|
||||||
<div class="absolute-full flex flex-center">
|
<q-card-section>
|
||||||
<q-badge
|
<div class="text-h6">Onchain</div>
|
||||||
color="white"
|
</q-card-section>
|
||||||
text-color="accent"
|
<q-card-section class="q-pa-none">
|
||||||
:label="gerty.onchain[0].difficulty[0].progressPercent.toFixed() + '%'"
|
<p v-for="(item, t) in dashboard_onchain">
|
||||||
/>
|
<b>{{item[0].value}}: </b>{{item[1].value}}
|
||||||
</div>
|
</p>
|
||||||
</q-linear-progress>
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card class="q-pa-sm" v-if="dashboard_mining" unelevated class="q-pa-sm">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Mining</div>
|
||||||
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<div class="row q-mt-lg q-gutter-sm text-h6">
|
<p v-for="(item, t) in dashboard_mining">
|
||||||
Current difficulty: {{gerty.onchain[2].threed[0].currentDifficulty}}
|
<b>{{item[0].value}}:</b> {{item[1].value}}
|
||||||
Current hashrate: {{gerty.onchain[2].threed[0].currentHashrate}}
|
</p>
|
||||||
</div>
|
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
|
||||||
<div v-if="gerty.ln[0]" class="col-12 col-sm-6 col-md-5 col-lg-6">
|
<q-card class="q-pa-sm" v-if="lightning_dashboard" unelevated class="q-pa-sm">
|
||||||
<q-card class="q-pa-lg">
|
<q-card-section>
|
||||||
<p class="text-h4">LN Stats</p>
|
<div class="text-h6">Lightning (Last 7 days)</div>
|
||||||
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<div class="row q-mt-lg q-gutter-sm">{{gerty.ln}}</div>
|
<p v-for="(item, t) in lightning_dashboard">
|
||||||
|
<b>{{item[0].value}}:</b> {{item[1].value}}
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card class="q-pa-sm" v-if="url_checker" unelevated class="q-pa-sm">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Servers to check</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<p v-for="(item, t) in url_checker">
|
||||||
|
<b>{{item[0].value.slice(0, 20)}}...:</b>
|
||||||
|
<q-chip
|
||||||
|
v-if="item[1].value < 300"
|
||||||
|
square
|
||||||
|
color="green"
|
||||||
|
text-color="white"
|
||||||
|
icon="sentiment_satisfied"
|
||||||
|
>
|
||||||
|
{{item[1].value}}
|
||||||
|
</q-chip>
|
||||||
|
<q-chip
|
||||||
|
v-else-if="item[1].value >= 300"
|
||||||
|
square
|
||||||
|
color="yellow"
|
||||||
|
text-color="white"
|
||||||
|
icon="sentiment_dissatisfied"
|
||||||
|
>
|
||||||
|
{{item[1].value}}
|
||||||
|
</q-chip>
|
||||||
|
<q-chip
|
||||||
|
v-else
|
||||||
|
square
|
||||||
|
color="red"
|
||||||
|
text-color="white"
|
||||||
|
icon="sentiment_dissatisfied"
|
||||||
|
>
|
||||||
|
{{item[1].value}}
|
||||||
|
</q-chip>
|
||||||
|
</p>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
</q-card>
|
</q-card>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endraw %} {% endblock %} {% block scripts %}
|
{% endraw %} {% endblock %} {% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
Vue.component(VueQrcode.name, VueQrcode)
|
Vue.component(VueQrcode.name, VueQrcode)
|
||||||
|
|
@ -96,41 +137,94 @@ endblock %}{% block page %} {% raw %}
|
||||||
mixins: [windowMixin],
|
mixins: [windowMixin],
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
gerty: {{ gerty | tojson }},
|
lnbits_wallets_balance: {},
|
||||||
|
dashboard_onchain: {},
|
||||||
|
fun_satoshi_quotes: {},
|
||||||
|
fun_exchange_market_rate: {},
|
||||||
|
gerty: [],
|
||||||
|
gerty_id: `{{gerty}}`,
|
||||||
|
gertyname: '',
|
||||||
walletColors: [
|
walletColors: [
|
||||||
{first: "#3f51b5",
|
{first: '#3f51b5', second: '#1a237e'},
|
||||||
second: "#1a237e"},
|
{first: '#9c27b0', second: '#4a148c'},
|
||||||
{first: "#9c27b0",
|
{first: '#e91e63', second: '#880e4f'},
|
||||||
second: "#4a148c"},
|
{first: '#009688', second: '#004d40'},
|
||||||
{first: "#e91e63",
|
{first: '#ff9800', second: '#e65100'},
|
||||||
second: "#880e4f"},
|
{first: '#2196f3', second: '#0d47a1'},
|
||||||
{first: "#009688",
|
{first: '#4caf50', second: '#1b5e20'}
|
||||||
second: "#004d40"},
|
|
||||||
{first: "#ff9800",
|
|
||||||
second: "#e65100"},
|
|
||||||
{first: "#2196f3",
|
|
||||||
second: "#0d47a1"},
|
|
||||||
{first: "#4caf50",
|
|
||||||
second: "#1b5e20"}
|
|
||||||
],
|
],
|
||||||
gertywallets: []
|
gertywallets: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getGertyInfo: async function () {
|
||||||
},
|
for (let i = 0; i < 8; i++) {
|
||||||
created: function () {
|
try {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
`/gerty/api/v1/gerty/pages/${this.gerty_id}/${i}`
|
||||||
|
)
|
||||||
|
this.gerty[i] = data
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
console.log(this.gerty)
|
console.log(this.gerty)
|
||||||
|
for (let i = 0; i < this.gerty.length; i++) {
|
||||||
|
if (this.gerty[i].screen.group == 'lnbits_wallets_balance') {
|
||||||
|
for (let q = 0; q < this.gerty[i].screen.areas.length; q++) {
|
||||||
|
this.lnbits_wallets_balance[q] = {
|
||||||
|
name: this.gerty[i].screen.areas[q][0].value,
|
||||||
|
amount: this.gerty[i].screen.areas[q][1].value,
|
||||||
|
color1: this.walletColors[q].first,
|
||||||
|
color2: this.walletColors[q].second
|
||||||
|
}
|
||||||
|
this.gertyname = this.gerty[i].settings.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.gerty[i].screen.group == 'url_checker') {
|
||||||
|
this.url_checker = this.gerty[i].screen.areas
|
||||||
|
this.gertyname = this.gerty[i].settings.name
|
||||||
|
}
|
||||||
|
if (this.gerty[i].screen.group == 'dashboard_onchain') {
|
||||||
|
this.dashboard_onchain = this.gerty[i].screen.areas
|
||||||
|
this.gertyname = this.gerty[i].settings.name
|
||||||
|
}
|
||||||
|
if (this.gerty[i].screen.group == 'dashboard_mining') {
|
||||||
|
this.dashboard_mining = this.gerty[i].screen.areas
|
||||||
|
this.gertyname = this.gerty[i].settings.name
|
||||||
|
}
|
||||||
|
if (this.gerty[i].screen.group == 'lightning_dashboard') {
|
||||||
|
this.lightning_dashboard = this.gerty[i].screen.areas
|
||||||
|
this.gertyname = this.gerty[i].settings.name
|
||||||
|
}
|
||||||
|
if (this.gerty[i].screen.group == 'fun_satoshi_quotes') {
|
||||||
|
this.fun_satoshi_quotes['quote'] = this.gerty[
|
||||||
|
i
|
||||||
|
].screen.areas[0][0].value
|
||||||
|
this.fun_satoshi_quotes['date'] = this.gerty[
|
||||||
|
i
|
||||||
|
].screen.areas[0][1].value
|
||||||
|
this.gertyname = this.gerty[i].settings.name
|
||||||
|
}
|
||||||
|
if (this.gerty[i].screen.group == 'fun_exchange_market_rate') {
|
||||||
|
this.fun_exchange_market_rate['unit'] = this.gerty[
|
||||||
|
i
|
||||||
|
].screen.areas[0][0].value
|
||||||
|
this.fun_exchange_market_rate['amount'] = this.gerty[
|
||||||
|
i
|
||||||
|
].screen.areas[0][1].value
|
||||||
|
this.gertyname = this.gerty[i].settings.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < this.gerty.wallets.length; i++) {
|
setTimeout(this.getGertyInfo, 20000)
|
||||||
this.gertywallets[i] = {
|
this.$forceUpdate()
|
||||||
"name":this.gerty.wallets[i].name,
|
return this.gerty
|
||||||
"amount":this.gerty.wallets[i].balance/1000 + " Sats",
|
|
||||||
"color1":this.walletColors[i].first,
|
|
||||||
"color2":this.walletColors[i].second
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
console.log(this.gertywallets)
|
created: async function () {
|
||||||
|
await this.getGertyInfo()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block page %}
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
<div class="row q-col-gutter-md">
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|
@ -106,9 +107,21 @@
|
||||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-9">
|
||||||
<h6 class="text-subtitle1 q-my-none">
|
<h6 class="text-subtitle1 q-my-none">
|
||||||
{{ SITE_TITLE }} Gerty extension
|
{{ SITE_TITLE }} Gerty extension
|
||||||
</h6>
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="Swagger API"
|
||||||
|
type="a"
|
||||||
|
href="../docs#/gerty"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pa-none">
|
<q-card-section class="q-pa-none">
|
||||||
<q-separator></q-separator>
|
<q-separator></q-separator>
|
||||||
|
|
@ -127,23 +140,71 @@
|
||||||
label="Name"
|
label="Name"
|
||||||
placeholder="Son of Gerty"
|
placeholder="Son of Gerty"
|
||||||
></q-input>
|
></q-input>
|
||||||
|
<q-checkbox
|
||||||
|
class="q-pl-md"
|
||||||
|
size="xs"
|
||||||
|
v-model="formDialog.data.display_preferences.fun_satoshi_quotes"
|
||||||
|
val="xs"
|
||||||
|
label="Satoshi Quotes"
|
||||||
|
><q-tooltip
|
||||||
|
>Displays random quotes from Satoshi</q-tooltip
|
||||||
|
></q-checkbox
|
||||||
|
>
|
||||||
|
<q-checkbox
|
||||||
|
class="q-pl-md"
|
||||||
|
size="xs"
|
||||||
|
v-model="formDialog.data.display_preferences.fun_exchange_market_rate"
|
||||||
|
val="xs"
|
||||||
|
label="Fiat to BTC price"
|
||||||
|
></q-checkbox>
|
||||||
|
<q-checkbox
|
||||||
|
class="q-pl-md"
|
||||||
|
size="xs"
|
||||||
|
v-model="formDialog.data.display_preferences.lnbits_wallets_balance"
|
||||||
|
val="xs"
|
||||||
|
label="LNbits"
|
||||||
|
></q-checkbox>
|
||||||
|
<q-checkbox
|
||||||
|
class="q-pl-md"
|
||||||
|
size="xs"
|
||||||
|
v-model="formDialog.data.display_preferences.dashboard_onchain"
|
||||||
|
val="xs"
|
||||||
|
label="Onchain"
|
||||||
|
></q-checkbox>
|
||||||
|
<q-checkbox
|
||||||
|
class="q-pl-md"
|
||||||
|
size="xs"
|
||||||
|
v-model="formDialog.data.display_preferences.dashboard_mining"
|
||||||
|
val="xs"
|
||||||
|
label="Mining"
|
||||||
|
></q-checkbox>
|
||||||
|
<q-checkbox
|
||||||
|
class="q-pl-md"
|
||||||
|
size="xs"
|
||||||
|
v-model="formDialog.data.display_preferences.lightning_dashboard"
|
||||||
|
val="xs"
|
||||||
|
label="Lightning"
|
||||||
|
></q-checkbox>
|
||||||
|
<q-checkbox
|
||||||
|
class="q-pl-md"
|
||||||
|
size="xs"
|
||||||
|
v-model="formDialog.data.display_preferences.url_checker"
|
||||||
|
val="xs"
|
||||||
|
label="URL Checker"
|
||||||
|
></q-checkbox>
|
||||||
|
<br />
|
||||||
<q-select
|
<q-select
|
||||||
|
v-if="formDialog.data.display_preferences.fun_exchange_market_rate"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
emit-value
|
emit-value
|
||||||
v-model="formDialog.data.type"
|
v-model="formDialog.data.exchange"
|
||||||
:options="['Gerty', 'Mini Gerty']"
|
:options="currencyOptions"
|
||||||
label="Gerty Type *"
|
label="Exchange rate"
|
||||||
></q-select>
|
|
||||||
<q-select
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
emit-value
|
|
||||||
v-model="formDialog.data.wallet"
|
|
||||||
:options="g.user.walletOptions"
|
|
||||||
label="Wallet *"
|
|
||||||
></q-select>
|
></q-select>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
|
v-if="formDialog.data.display_preferences.lnbits_wallets_balance"
|
||||||
filled
|
filled
|
||||||
multiple
|
multiple
|
||||||
dense
|
dense
|
||||||
|
|
@ -158,199 +219,57 @@
|
||||||
>
|
>
|
||||||
<q-tooltip>Hit enter to add values</q-tooltip>
|
<q-tooltip>Hit enter to add values</q-tooltip>
|
||||||
</q-select>
|
</q-select>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
|
v-if="formDialog.data.display_preferences.url_checker"
|
||||||
filled
|
filled
|
||||||
|
multiple
|
||||||
dense
|
dense
|
||||||
emit-value
|
emit-value
|
||||||
v-model="formDialog.data.exchange"
|
v-model="formDialog.data.urls"
|
||||||
:options="currencyOptions"
|
use-input
|
||||||
label="Exchange rate"
|
use-chips
|
||||||
></q-select>
|
multiple
|
||||||
|
hide-dropdown-icon
|
||||||
|
new-value-mode="add-unique"
|
||||||
|
label="Urls to watch."
|
||||||
|
>
|
||||||
|
<q-tooltip>Hit enter to add values</q-tooltip>
|
||||||
|
</q-select>
|
||||||
|
|
||||||
|
<q-toggle
|
||||||
|
label="*Advanced"
|
||||||
|
v-model="toggleStates.advanced"
|
||||||
|
@input="setAdvanced"
|
||||||
|
></q-toggle>
|
||||||
|
<br />
|
||||||
<q-input
|
<q-input
|
||||||
|
v-if="toggleStates.advanced"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.mempool_endpoint"
|
v-model.trim="formDialog.data.mempool_endpoint"
|
||||||
label="Mempool link"
|
label="Mempool link"
|
||||||
|
class="q-pb-sm"
|
||||||
>
|
>
|
||||||
<q-tooltip>Used for getting onchain/ln stats</q-tooltip>
|
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
|
v-if="toggleStates.advanced"
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.refresh_time"
|
v-model.trim="formDialog.data.refresh_time"
|
||||||
label="Refresh time in seconds"
|
label="Refresh time in seconds"
|
||||||
|
class="q-pb-md"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
>The amount of time in seconds between screen updates
|
>The amount of time in seconds between screen updates
|
||||||
</q-tooltip
|
</q-tooltip>
|
||||||
>
|
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
<q-input
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
v-model.trim="formDialog.data.utc_offset"
|
|
||||||
label="UTC Time Offset (e.g. -1)"
|
|
||||||
>
|
|
||||||
<q-tooltip>Enter a UTC time offset value (e.g. -1)</q-tooltip>
|
|
||||||
</q-input>
|
|
||||||
|
|
||||||
<p>Use the toggles below to control what your Gerty will display</p>
|
|
||||||
|
|
||||||
|
|
||||||
<q-expansion-item
|
|
||||||
v-if="!isMiniGerty"
|
|
||||||
expand-separator
|
|
||||||
icon="grid_view"
|
|
||||||
label="Dashboards"
|
|
||||||
>
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.dashboard"
|
|
||||||
label="LNbits Dashboard"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.dashboard_onchain"
|
|
||||||
label="Onchain Dashboard"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.dashboard_mining"
|
|
||||||
label="Mining Dashboard"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.lightning_dashboard"
|
|
||||||
label="Lightning Network Dashboard"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
||||||
|
|
||||||
<q-expansion-item
|
|
||||||
expand-separator
|
|
||||||
icon="pin"
|
|
||||||
label="Single Data Points"
|
|
||||||
ref="single-data-points-expansion"
|
|
||||||
>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.fun_exchange_market_rate"
|
|
||||||
label="Fiat to BTC price"
|
|
||||||
></q-toggle>
|
|
||||||
<q-toggle
|
|
||||||
v-if="!isMiniGerty"
|
|
||||||
v-model="formDialog.data.display_preferences.fun_satoshi_quotes"
|
|
||||||
label="Satoshi Quotes"
|
|
||||||
>
|
|
||||||
<q-tooltip>Displays random quotes from Satoshi</q-tooltip>
|
|
||||||
</q-toggle>
|
|
||||||
<q-expansion-item
|
|
||||||
expand-separator
|
|
||||||
icon="perm_identity"
|
|
||||||
label="LNbits Wallets"
|
|
||||||
>
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.lnbits_wallets_balance"
|
|
||||||
label="Show LNbits wallet balances"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
||||||
|
|
||||||
<q-expansion-item
|
|
||||||
expand-separator
|
|
||||||
icon="link"
|
|
||||||
label="Onchain Information"
|
|
||||||
>
|
|
||||||
<q-toggle
|
|
||||||
v-model="toggleStates.onchain"
|
|
||||||
label="Toggle all"
|
|
||||||
>
|
|
||||||
<q-tooltip>Toggle all</q-tooltip>
|
|
||||||
</q-toggle>
|
|
||||||
<br>
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.onchain_difficulty_epoch_progress"
|
|
||||||
label="Percent of current difficulty epoch complete"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.onchain_difficulty_retarget_date"
|
|
||||||
label="Estimated retarget date"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.onchain_difficulty_blocks_remaining"
|
|
||||||
label="Blocks until next difficulty adjustment"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.onchain_difficulty_epoch_time_remaining"
|
|
||||||
label="Estimated time until next difficulty adjustment"
|
|
||||||
></q-toggle>
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.onchain_block_height"
|
|
||||||
label="Current block height"
|
|
||||||
></q-toggle>
|
|
||||||
</q-expansion-item>
|
|
||||||
<q-expansion-item
|
|
||||||
expand-separator
|
|
||||||
icon="psychology"
|
|
||||||
label="The Mempool"
|
|
||||||
>
|
|
||||||
<q-toggle
|
|
||||||
v-model="toggleStates.mempool"
|
|
||||||
label="Toggle all"
|
|
||||||
>
|
|
||||||
<q-tooltip>Toggle all</q-tooltip>
|
|
||||||
</q-toggle>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.mempool_recommended_fees"
|
|
||||||
v-if="!isMiniGerty"
|
|
||||||
label="Recommended fees"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.mempool_tx_count"
|
|
||||||
label="Number of transactions in the mempool"
|
|
||||||
></q-toggle>
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
||||||
<q-expansion-item
|
|
||||||
expand-separator
|
|
||||||
icon="money"
|
|
||||||
label="Mining Data"
|
|
||||||
>
|
|
||||||
<q-toggle
|
|
||||||
v-model="toggleStates.mining"
|
|
||||||
label="Toggle all"
|
|
||||||
>
|
|
||||||
<q-tooltip>Toggle all</q-tooltip>
|
|
||||||
</q-toggle>
|
|
||||||
<br>
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.mining_current_hash_rate"
|
|
||||||
label="Current mining hashrate"
|
|
||||||
></q-toggle>
|
|
||||||
|
|
||||||
<q-toggle
|
|
||||||
v-model="formDialog.data.display_preferences.mining_current_difficulty"
|
|
||||||
label="Current mining difficulty"
|
|
||||||
></q-toggle>
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
||||||
</q-expansion-item>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="row q-mt-lg">
|
<div class="row q-mt-lg">
|
||||||
<q-btn
|
<q-btn
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
:disable="formDialog.data.wallet == null || formDialog.data.name == null"
|
:disable="formDialog.data.name == null"
|
||||||
type="submit"
|
type="submit"
|
||||||
class="q-mr-md"
|
class="q-mr-md"
|
||||||
v-if="!formDialog.data.id"
|
v-if="!formDialog.data.id"
|
||||||
|
|
@ -360,7 +279,7 @@
|
||||||
v-else
|
v-else
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
:disable="formDialog.data.wallet == null || formDialog.data.name == null"
|
:disable="formDialog.data.name == null"
|
||||||
type="submit"
|
type="submit"
|
||||||
>Update Gerty
|
>Update Gerty
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
|
@ -371,9 +290,9 @@
|
||||||
</q-form>
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
<script>
|
<script>
|
||||||
var mapGerty = function (obj) {
|
var mapGerty = function (obj) {
|
||||||
obj.date = Quasar.utils.date.formatDate(
|
obj.date = Quasar.utils.date.formatDate(
|
||||||
new Date(obj.time * 1000),
|
new Date(obj.time * 1000),
|
||||||
|
|
@ -381,7 +300,7 @@
|
||||||
)
|
)
|
||||||
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||||
obj.gerty = ['/gerty/', obj.id].join('')
|
obj.gerty = ['/gerty/', obj.id].join('')
|
||||||
obj.gertyJson = ['/gerty/api/v1/gerty/', obj.id, '/0'].join('')
|
obj.gertyJson = ['/gerty/api/v1/gerty/pages/', obj.id, '/0'].join('')
|
||||||
return obj
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -391,11 +310,12 @@
|
||||||
data: function () {
|
data: function () {
|
||||||
return {
|
return {
|
||||||
toggleStates: {
|
toggleStates: {
|
||||||
fun: true,
|
fun: false,
|
||||||
onchain: true,
|
onchain: false,
|
||||||
mempool: true,
|
mempool: false,
|
||||||
mining: true,
|
mining: false,
|
||||||
lightning: true
|
lightning: false,
|
||||||
|
advanced: false
|
||||||
},
|
},
|
||||||
oldToggleStates: {},
|
oldToggleStates: {},
|
||||||
gertys: [],
|
gertys: [],
|
||||||
|
|
@ -580,7 +500,7 @@
|
||||||
name: 'exchange',
|
name: 'exchange',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
label: 'Exchange',
|
label: 'Exchange',
|
||||||
field: 'exchange',
|
field: 'exchange'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'mempool_endpoint',
|
name: 'mempool_endpoint',
|
||||||
|
|
@ -588,8 +508,7 @@
|
||||||
label: 'Mempool Endpoint',
|
label: 'Mempool Endpoint',
|
||||||
field: 'mempool_endpoint'
|
field: 'mempool_endpoint'
|
||||||
},
|
},
|
||||||
{name: 'id', align: 'left', label: 'Gerty ID', field: 'id'},
|
{name: 'id', align: 'left', label: 'Gerty ID', field: 'id'}
|
||||||
|
|
||||||
],
|
],
|
||||||
pagination: {
|
pagination: {
|
||||||
rowsPerPage: 10
|
rowsPerPage: 10
|
||||||
|
|
@ -598,47 +517,83 @@
|
||||||
formDialog: {
|
formDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
data: {
|
data: {
|
||||||
utc_offset: 0,
|
type: 'Mini Gerty',
|
||||||
type: 'Gerty',
|
exchange: 'USD',
|
||||||
|
utc_offset: new Date().getTimezoneOffset(),
|
||||||
display_preferences: {
|
display_preferences: {
|
||||||
dashboard: true,
|
dashboard: false,
|
||||||
fun_satoshi_quotes: true,
|
fun_satoshi_quotes: false,
|
||||||
fun_exchange_market_rate: true,
|
fun_exchange_market_rate: false,
|
||||||
dashboard_onchain: true,
|
dashboard_onchain: false,
|
||||||
mempool_recommended_fees: true,
|
mempool_recommended_fees: false,
|
||||||
dashboard_mining: true,
|
dashboard_mining: false,
|
||||||
lightning_dashboard: true,
|
lightning_dashboard: false,
|
||||||
onchain: true,
|
onchain: false,
|
||||||
onchain_difficulty_epoch_progress: true,
|
onchain_difficulty_epoch_progress: false,
|
||||||
onchain_difficulty_retarget_date: true,
|
onchain_difficulty_retarget_date: false,
|
||||||
onchain_difficulty_blocks_remaining: true,
|
onchain_difficulty_blocks_remaining: false,
|
||||||
onchain_difficulty_epoch_time_remaining: true,
|
onchain_difficulty_epoch_time_remaining: false,
|
||||||
onchain_block_height: true,
|
onchain_block_height: false,
|
||||||
mempool_tx_count: true,
|
mempool_tx_count: false,
|
||||||
mining_current_hash_rate: true,
|
mining_current_hash_rate: false,
|
||||||
mining_current_difficulty: true,
|
mining_current_difficulty: false,
|
||||||
lnbits_wallets_balance: true,
|
lnbits_wallets_balance: false,
|
||||||
|
url_checker: false
|
||||||
},
|
},
|
||||||
lnbits_wallets: [],
|
lnbits_wallets: [],
|
||||||
mempool_endpoint: "https://mempool.space",
|
urls: [],
|
||||||
refresh_time: 300,
|
mempool_endpoint: 'https://mempool.space',
|
||||||
|
refresh_time: 300
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
|
||||||
console.log('this.formDialog', this.formDialog.data.display_preferences)
|
|
||||||
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
|
setAdvanced: function () {
|
||||||
|
self = this
|
||||||
|
self.formDialog.data.mempool_endpoint = 'https://mempool.space'
|
||||||
|
self.formDialog.data.refresh_time = 300
|
||||||
|
},
|
||||||
|
setWallets: function () {
|
||||||
|
self = this
|
||||||
|
if (!self.formDialog.data.display_preferences.lnbits_wallets_balance) {
|
||||||
|
self.formDialog.data.lnbits_wallets = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setUrls: function () {
|
||||||
|
self = this
|
||||||
|
if (!self.formDialog.data.display_preferences.url_checker) {
|
||||||
|
self.formDialog.data.urls = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setOnchain: function () {
|
||||||
|
self = this
|
||||||
|
self.formDialog.data.display_preferences.onchain_difficulty_epoch_progress =
|
||||||
|
self.toggleStates.onchain
|
||||||
|
self.formDialog.data.display_preferences.onchain_difficulty_retarget_date =
|
||||||
|
self.toggleStates.onchain
|
||||||
|
self.formDialog.data.display_preferences.onchain_difficulty_blocks_remaining = !self
|
||||||
|
.toggleStates.onchain
|
||||||
|
self.formDialog.data.display_preferences.onchain_difficulty_epoch_time_remaining =
|
||||||
|
self.toggleStates.onchain
|
||||||
|
self.formDialog.data.display_preferences.onchain_block_height =
|
||||||
|
self.toggleStates.onchain
|
||||||
|
},
|
||||||
|
setMining: function () {
|
||||||
|
self = this
|
||||||
|
self.formDialog.data.display_preferences.mining_current_hash_rate =
|
||||||
|
self.toggleStates.mining
|
||||||
|
self.formDialog.data.display_preferences.mining_current_difficulty =
|
||||||
|
self.toggleStates.mining
|
||||||
|
},
|
||||||
closeFormDialog: function () {
|
closeFormDialog: function () {
|
||||||
this.formDialog.data = {
|
this.formDialog.data = {
|
||||||
utc_offset: 0,
|
utc_offset: 0,
|
||||||
lnbits_wallets: [],
|
lnbits_wallets: [],
|
||||||
mempool_endpoint: "https://mempool.space",
|
urls: [],
|
||||||
|
mempool_endpoint: 'https://mempool.space',
|
||||||
refresh_time: 300,
|
refresh_time: 300,
|
||||||
type: 'Gerty',
|
display_preferences: {}
|
||||||
display_preferences: {},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getGertys: function () {
|
getGertys: function () {
|
||||||
|
|
@ -657,18 +612,19 @@
|
||||||
},
|
},
|
||||||
updateformDialog: function (formId) {
|
updateformDialog: function (formId) {
|
||||||
var gerty = _.findWhere(this.gertys, {id: formId})
|
var gerty = _.findWhere(this.gertys, {id: formId})
|
||||||
console.log('gerty.display_preferences', gerty.display_preferences)
|
|
||||||
this.formDialog.data.id = gerty.id
|
this.formDialog.data.id = gerty.id
|
||||||
this.formDialog.data.name = gerty.name
|
this.formDialog.data.name = gerty.name
|
||||||
this.formDialog.data.type = gerty.type
|
this.formDialog.data.type = gerty.type
|
||||||
this.formDialog.data.wallet = gerty.wallet
|
|
||||||
this.formDialog.data.utc_offset = gerty.utc_offset
|
this.formDialog.data.utc_offset = gerty.utc_offset
|
||||||
this.formDialog.data.lnbits_wallets = JSON.parse(gerty.lnbits_wallets)
|
this.formDialog.data.lnbits_wallets = JSON.parse(gerty.lnbits_wallets)
|
||||||
this.formDialog.data.exchange = gerty.exchange,
|
this.formDialog.data.urls = JSON.parse(gerty.urls)
|
||||||
this.formDialog.data.mempool_endpoint = gerty.mempool_endpoint,
|
;(this.formDialog.data.exchange = gerty.exchange),
|
||||||
this.formDialog.data.refresh_time = gerty.refresh_time,
|
(this.formDialog.data.mempool_endpoint = gerty.mempool_endpoint),
|
||||||
this.formDialog.data.display_preferences = JSON.parse(gerty.display_preferences),
|
(this.formDialog.data.refresh_time = gerty.refresh_time),
|
||||||
this.formDialog.show = true
|
(this.formDialog.data.display_preferences = JSON.parse(
|
||||||
|
gerty.display_preferences
|
||||||
|
)),
|
||||||
|
(this.formDialog.show = true)
|
||||||
},
|
},
|
||||||
sendFormDataGerty: function () {
|
sendFormDataGerty: function () {
|
||||||
if (this.formDialog.data.id) {
|
if (this.formDialog.data.id) {
|
||||||
|
|
@ -677,7 +633,6 @@
|
||||||
this.formDialog.data
|
this.formDialog.data
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
{#console.log('sendFormDataGerty', this.formDialog.data)#}
|
|
||||||
this.createGerty(
|
this.createGerty(
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
this.formDialog.data
|
this.formDialog.data
|
||||||
|
|
@ -685,26 +640,34 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createGerty: function () {
|
createGerty: function () {
|
||||||
|
if (
|
||||||
|
this.formDialog.data.display_preferences.dashboard ||
|
||||||
|
this.formDialog.data.display_preferences.dashboard_onchain ||
|
||||||
|
this.formDialog.data.display_preferences.dashboard_onchain ||
|
||||||
|
this.formDialog.data.display_preferences.lightning_dashboard ||
|
||||||
|
this.formDialog.data.display_preferences.url_checker
|
||||||
|
) {
|
||||||
|
this.formDialog.data.type = 'Gerty'
|
||||||
|
}
|
||||||
var data = {
|
var data = {
|
||||||
name: this.formDialog.data.name,
|
name: this.formDialog.data.name,
|
||||||
wallet: this.formDialog.data.wallet,
|
|
||||||
utc_offset: this.formDialog.data.utc_offset,
|
utc_offset: this.formDialog.data.utc_offset,
|
||||||
type: this.formDialog.data.type,
|
type: this.formDialog.data.type,
|
||||||
lnbits_wallets: JSON.stringify(this.formDialog.data.lnbits_wallets),
|
lnbits_wallets: JSON.stringify(this.formDialog.data.lnbits_wallets),
|
||||||
|
urls: JSON.stringify(this.formDialog.data.urls),
|
||||||
exchange: this.formDialog.data.exchange,
|
exchange: this.formDialog.data.exchange,
|
||||||
mempool_endpoint: this.formDialog.data.mempool_endpoint,
|
mempool_endpoint: this.formDialog.data.mempool_endpoint,
|
||||||
refresh_time: this.formDialog.data.refresh_time,
|
refresh_time: this.formDialog.data.refresh_time,
|
||||||
display_preferences: JSON.stringify(this.formDialog.data.display_preferences)
|
display_preferences: JSON.stringify(
|
||||||
|
this.formDialog.data.display_preferences
|
||||||
|
)
|
||||||
}
|
}
|
||||||
console.log('createGerty', data)
|
|
||||||
var self = this
|
var self = this
|
||||||
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'POST',
|
'POST',
|
||||||
'/gerty/api/v1/gerty',
|
'/gerty/api/v1/gerty',
|
||||||
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
this.g.user.wallets[0].inkey,
|
||||||
.inkey,
|
|
||||||
data
|
data
|
||||||
)
|
)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
|
|
@ -717,17 +680,26 @@
|
||||||
},
|
},
|
||||||
updateGerty: function (wallet, data) {
|
updateGerty: function (wallet, data) {
|
||||||
var self = this
|
var self = this
|
||||||
|
if (
|
||||||
|
this.formDialog.data.display_preferences.dashboard ||
|
||||||
|
this.formDialog.data.display_preferences.dashboard_onchain ||
|
||||||
|
this.formDialog.data.display_preferences.dashboard_onchain ||
|
||||||
|
this.formDialog.data.display_preferences.lightning_dashboard ||
|
||||||
|
this.formDialog.data.display_preferences.url_checker
|
||||||
|
) {
|
||||||
|
this.formDialog.data.type = 'Gerty'
|
||||||
|
}
|
||||||
data.utc_offset = this.formDialog.data.utc_offset
|
data.utc_offset = this.formDialog.data.utc_offset
|
||||||
data.type = this.formDialog.data.type
|
data.type = this.formDialog.data.type
|
||||||
data.lnbits_wallets = JSON.stringify(this.formDialog.data.lnbits_wallets)
|
data.lnbits_wallets = JSON.stringify(
|
||||||
data.display_preferences = JSON.stringify(this.formDialog.data.display_preferences)
|
this.formDialog.data.lnbits_wallets
|
||||||
LNbits.api
|
|
||||||
.request(
|
|
||||||
'PUT',
|
|
||||||
'/gerty/api/v1/gerty/' + data.id,
|
|
||||||
wallet,
|
|
||||||
data
|
|
||||||
)
|
)
|
||||||
|
data.urls = JSON.stringify(this.formDialog.data.urls)
|
||||||
|
data.display_preferences = JSON.stringify(
|
||||||
|
this.formDialog.data.display_preferences
|
||||||
|
)
|
||||||
|
LNbits.api
|
||||||
|
.request('PUT', '/gerty/api/v1/gerty/' + data.id, wallet, data)
|
||||||
.then(function (response) {
|
.then(function (response) {
|
||||||
self.gertys = _.reject(self.gertys, function (obj) {
|
self.gertys = _.reject(self.gertys, function (obj) {
|
||||||
return obj.id == data.id
|
return obj.id == data.id
|
||||||
|
|
@ -741,8 +713,8 @@
|
||||||
},
|
},
|
||||||
deleteGerty: function (gertyId) {
|
deleteGerty: function (gertyId) {
|
||||||
var self = this
|
var self = this
|
||||||
var gerty = _.findWhere(this.gertys, {id: gertyId})
|
|
||||||
|
|
||||||
|
var gerty = _.findWhere(self.gertys, {id: gertyId})
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
.confirmDialog('Are you sure you want to delete this Gerty?')
|
.confirmDialog('Are you sure you want to delete this Gerty?')
|
||||||
.onOk(function () {
|
.onOk(function () {
|
||||||
|
|
@ -768,7 +740,7 @@
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
isMiniGerty() {
|
isMiniGerty() {
|
||||||
return (this.formDialog.data.type == 'Mini Gerty')
|
return this.formDialog.data.type == 'Mini Gerty'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created: function () {
|
created: function () {
|
||||||
|
|
@ -780,22 +752,27 @@
|
||||||
'formDialog.data.type': {
|
'formDialog.data.type': {
|
||||||
handler(value) {
|
handler(value) {
|
||||||
if (value == 'Mini Gerty') {
|
if (value == 'Mini Gerty') {
|
||||||
this.formDialog.data.display_preferences.dashboard = false;
|
this.formDialog.data.display_preferences.dashboard = false
|
||||||
this.formDialog.data.display_preferences.dashboard_onchain = false;
|
this.formDialog.data.display_preferences.dashboard_onchain = false
|
||||||
this.formDialog.data.display_preferences.dashboard_mining = false;
|
this.formDialog.data.display_preferences.dashboard_mining = false
|
||||||
this.formDialog.data.display_preferences.lightning_dashboard = false;
|
this.formDialog.data.display_preferences.lightning_dashboard = false
|
||||||
this.formDialog.data.display_preferences.fun_satoshi_quotes = false;
|
this.formDialog.data.display_preferences.fun_satoshi_quotes = false
|
||||||
this.formDialog.data.display_preferences.mempool_recommended_fees = false;
|
this.formDialog.data.display_preferences.mempool_recommended_fees = false
|
||||||
this.formDialog.data.display_preferences.onchain = false;
|
this.formDialog.data.display_preferences.onchain = false
|
||||||
|
this.formDialog.data.display_preferences.url_checker = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleStates: {
|
toggleStates: {
|
||||||
handler(toggleStatesValue) {
|
handler(toggleStatesValue) {
|
||||||
// Switch all the toggles in each section to the relevant state
|
// Switch all the toggles in each section to the relevant state
|
||||||
for (const [toggleKey, toggleValue] of Object.entries(toggleStatesValue)) {
|
for (const [toggleKey, toggleValue] of Object.entries(
|
||||||
|
toggleStatesValue
|
||||||
|
)) {
|
||||||
if (this.oldToggleStates[toggleKey] !== toggleValue) {
|
if (this.oldToggleStates[toggleKey] !== toggleValue) {
|
||||||
for (const [dpKey, dpValue] of Object.entries(this.formDialog.data.display_preferences)) {
|
for (const [dpKey, dpValue] of Object.entries(
|
||||||
|
this.formDialog.data.display_preferences
|
||||||
|
)) {
|
||||||
if (dpKey.indexOf(toggleKey) === 0) {
|
if (dpKey.indexOf(toggleKey) === 0) {
|
||||||
this.formDialog.data.display_preferences[dpKey] = toggleValue
|
this.formDialog.data.display_preferences[dpKey] = toggleValue
|
||||||
}
|
}
|
||||||
|
|
@ -811,11 +788,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
{% endblock %} {% block styles %}
|
{% endblock %} {% block styles %}
|
||||||
<style>
|
<style>
|
||||||
.col__display_preferences {
|
.col__display_preferences {
|
||||||
border: 1px solid red;
|
border: 1px solid red;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
from lnbits.decorators import check_user_exists
|
from lnbits.decorators import check_user_exists
|
||||||
from lnbits.settings import LNBITS_CUSTOM_LOGO, LNBITS_SITE_TITLE
|
|
||||||
|
|
||||||
from . import gerty_ext, gerty_renderer
|
from . import gerty_ext, gerty_renderer
|
||||||
from .crud import get_gerty
|
from .crud import get_gerty
|
||||||
|
|
@ -33,7 +32,6 @@ async def display(request: Request, gerty_id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist."
|
||||||
)
|
)
|
||||||
gertyData = await api_gerty_json(gerty_id)
|
|
||||||
return gerty_renderer().TemplateResponse(
|
return gerty_renderer().TemplateResponse(
|
||||||
"gerty/gerty.html", {"request": request, "gerty": gertyData}
|
"gerty/gerty.html", {"request": request, "gerty": gerty_id}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ from lnbits.core.views.api import api_payment, api_wallet
|
||||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||||
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
||||||
|
|
||||||
from ...settings import LNBITS_PATH
|
|
||||||
from . import gerty_ext
|
from . import gerty_ext
|
||||||
from .crud import create_gerty, delete_gerty, get_gerty, get_gertys, update_gerty
|
from .crud import (create_gerty, delete_gerty, get_gerty, get_gertys,
|
||||||
|
get_mempool_info, update_gerty)
|
||||||
from .helpers import *
|
from .helpers import *
|
||||||
from .models import Gerty
|
from .models import Gerty, MempoolEndpoint
|
||||||
|
|
||||||
|
|
||||||
@gerty_ext.get("/api/v1/gerty", status_code=HTTPStatus.OK)
|
@gerty_ext.get("/api/v1/gerty", status_code=HTTPStatus.OK)
|
||||||
|
|
@ -45,6 +45,7 @@ async def api_link_create_or_update(
|
||||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||||
gerty_id: str = Query(None),
|
gerty_id: str = Query(None),
|
||||||
):
|
):
|
||||||
|
logger.debug(data)
|
||||||
if gerty_id:
|
if gerty_id:
|
||||||
gerty = await get_gerty(gerty_id)
|
gerty = await get_gerty(gerty_id)
|
||||||
if not gerty:
|
if not gerty:
|
||||||
|
|
@ -84,24 +85,12 @@ async def api_gerty_delete(
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
#######################
|
|
||||||
|
|
||||||
|
|
||||||
@gerty_ext.get("/api/v1/gerty/satoshiquote", status_code=HTTPStatus.OK)
|
@gerty_ext.get("/api/v1/gerty/satoshiquote", status_code=HTTPStatus.OK)
|
||||||
async def api_gerty_satoshi():
|
async def api_gerty_satoshi():
|
||||||
maxQuoteLength = 186
|
return await get_satoshi
|
||||||
with open(os.path.join(LNBITS_PATH, "extensions/gerty/static/satoshi.json")) as fd:
|
|
||||||
satoshiQuotes = json.load(fd)
|
|
||||||
quote = satoshiQuotes[random.randint(0, len(satoshiQuotes) - 1)]
|
|
||||||
# logger.debug(quote.text)
|
|
||||||
if len(quote["text"]) > maxQuoteLength:
|
|
||||||
logger.debug("Quote is too long, getting another")
|
|
||||||
return await api_gerty_satoshi()
|
|
||||||
else:
|
|
||||||
return quote
|
|
||||||
|
|
||||||
|
|
||||||
@gerty_ext.get("/api/v1/gerty/{gerty_id}/{p}")
|
@gerty_ext.get("/api/v1/gerty/pages/{gerty_id}/{p}")
|
||||||
async def api_gerty_json(gerty_id: str, p: int = None): # page number
|
async def api_gerty_json(gerty_id: str, p: int = None): # page number
|
||||||
gerty = await get_gerty(gerty_id)
|
gerty = await get_gerty(gerty_id)
|
||||||
|
|
||||||
|
|
@ -151,378 +140,46 @@ async def api_gerty_json(gerty_id: str, p: int = None): # page number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Get a screen slug by its position in the screens_list
|
###########CACHED MEMPOOL##############
|
||||||
def get_screen_slug_by_index(index: int, screens_list):
|
|
||||||
logger.debug("Index: {0}".format(index))
|
|
||||||
logger.debug("len(screens_list) - 1: {0} ".format(len(screens_list) - 1))
|
|
||||||
if index <= len(screens_list) - 1:
|
|
||||||
return list(screens_list)[index - 1]
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Get a list of text items for the screen number
|
@gerty_ext.get("/api/v1/gerty/fees-recommended/{gerty_id}")
|
||||||
async def get_screen_data(screen_num: int, screens_list: dict, gerty):
|
async def api_gerty_get_fees_recommended(gerty_id):
|
||||||
screen_slug = get_screen_slug_by_index(screen_num, screens_list)
|
gerty = await get_gerty(gerty_id)
|
||||||
# first get the relevant slug from the display_preferences
|
return await get_mempool_info("fees_recommended", gerty)
|
||||||
logger.debug("screen_slug")
|
|
||||||
logger.debug(screen_slug)
|
|
||||||
areas = []
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
if screen_slug == "dashboard":
|
|
||||||
title = gerty.name
|
|
||||||
areas = await get_dashboard(gerty)
|
|
||||||
if screen_slug == "lnbits_wallets_balance":
|
|
||||||
wallets = await get_lnbits_wallet_balances(gerty)
|
|
||||||
text = []
|
|
||||||
for wallet in wallets:
|
|
||||||
text.append(get_text_item_dict(text="{0}'s Wallet".format(wallet['name']), font_size=20,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text="{0} sats".format(format_number(wallet['balance'])), font_size=40,gerty_type=gerty.type))
|
|
||||||
areas.append(text)
|
|
||||||
elif screen_slug == "fun_satoshi_quotes":
|
|
||||||
areas.append(await get_satoshi_quotes(gerty))
|
|
||||||
elif screen_slug == "fun_exchange_market_rate":
|
|
||||||
areas.append(await get_exchange_rate(gerty))
|
|
||||||
elif screen_slug == "onchain_difficulty_epoch_progress":
|
|
||||||
areas.append(await get_onchain_stat(screen_slug, gerty))
|
|
||||||
elif screen_slug == "onchain_block_height":
|
|
||||||
logger.debug("iam block height")
|
|
||||||
text = []
|
|
||||||
text.append(get_text_item_dict(text=format_number(await get_block_height(gerty)), font_size=80, gerty_type=gerty.type))
|
|
||||||
areas.append(text)
|
|
||||||
elif screen_slug == "onchain_difficulty_retarget_date":
|
|
||||||
areas.append(await get_onchain_stat(screen_slug, gerty))
|
|
||||||
elif screen_slug == "onchain_difficulty_blocks_remaining":
|
|
||||||
areas.append(await get_onchain_stat(screen_slug, gerty))
|
|
||||||
elif screen_slug == "onchain_difficulty_epoch_time_remaining":
|
|
||||||
areas.append(await get_onchain_stat(screen_slug, gerty))
|
|
||||||
elif screen_slug == "dashboard_onchain":
|
|
||||||
title = "Onchain Data"
|
|
||||||
areas = await get_onchain_dashboard(gerty)
|
|
||||||
elif screen_slug == "mempool_recommended_fees":
|
|
||||||
areas.append(await get_mempool_stat(screen_slug, gerty))
|
|
||||||
elif screen_slug == "mempool_tx_count":
|
|
||||||
areas.append(await get_mempool_stat(screen_slug, gerty))
|
|
||||||
elif screen_slug == "mining_current_hash_rate":
|
|
||||||
areas.append(await get_mining_stat(screen_slug, gerty))
|
|
||||||
elif screen_slug == "mining_current_difficulty":
|
|
||||||
areas.append(await get_mining_stat(screen_slug, gerty))
|
|
||||||
elif screen_slug == "dashboard_mining":
|
|
||||||
title = "Mining Data"
|
|
||||||
areas = await get_mining_dashboard(gerty)
|
|
||||||
elif screen_slug == "lightning_dashboard":
|
|
||||||
title = "Lightning Network"
|
|
||||||
areas = await get_lightning_stats(gerty)
|
|
||||||
|
|
||||||
data = {}
|
|
||||||
data["title"] = title
|
|
||||||
data["areas"] = areas
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
# Get the dashboard screen
|
@gerty_ext.get("/api/v1/gerty/hashrate-1w/{gerty_id}")
|
||||||
async def get_dashboard(gerty):
|
async def api_gerty_get_hashrate_1w(gerty_id):
|
||||||
areas = []
|
gerty = await get_gerty(gerty_id)
|
||||||
# XC rate
|
return await get_mempool_info("hashrate_1w", gerty)
|
||||||
text = []
|
|
||||||
amount = await satoshis_amount_as_fiat(100000000, gerty.exchange)
|
|
||||||
text.append(get_text_item_dict(text=format_number(amount), font_size=40,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text="BTC{0} price".format(gerty.exchange), font_size=15,gerty_type=gerty.type))
|
|
||||||
areas.append(text)
|
|
||||||
# balance
|
|
||||||
text = []
|
|
||||||
wallets = await get_lnbits_wallet_balances(gerty)
|
|
||||||
text = []
|
|
||||||
for wallet in wallets:
|
|
||||||
text.append(get_text_item_dict(text="{0}".format(wallet["name"]), font_size=15,gerty_type=gerty.type))
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict(text="{0} sats".format(format_number(wallet["balance"])), font_size=20,gerty_type=gerty.type)
|
|
||||||
)
|
|
||||||
areas.append(text)
|
|
||||||
|
|
||||||
# Mempool fees
|
|
||||||
text = []
|
|
||||||
text.append(get_text_item_dict(text=format_number(await get_block_height(gerty)), font_size=40,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text="Current block height", font_size=15,gerty_type=gerty.type))
|
|
||||||
areas.append(text)
|
|
||||||
|
|
||||||
# difficulty adjustment time
|
|
||||||
text = []
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict(
|
|
||||||
text=await get_time_remaining_next_difficulty_adjustment(gerty), font_size=15,gerty_type=gerty.type
|
|
||||||
)
|
|
||||||
)
|
|
||||||
text.append(get_text_item_dict(text="until next difficulty adjustment", font_size=12,gerty_type=gerty.type))
|
|
||||||
areas.append(text)
|
|
||||||
|
|
||||||
return areas
|
|
||||||
|
|
||||||
|
|
||||||
async def get_lnbits_wallet_balances(gerty):
|
@gerty_ext.get("/api/v1/gerty/hashrate-1m/{gerty_id}")
|
||||||
# Get Wallet info
|
async def api_gerty_get_hashrate_1m(gerty_id):
|
||||||
wallets = []
|
gerty = await get_gerty(gerty_id)
|
||||||
if gerty.lnbits_wallets != "":
|
return await get_mempool_info("hashrate_1m", gerty)
|
||||||
for lnbits_wallet in json.loads(gerty.lnbits_wallets):
|
|
||||||
wallet = await get_wallet_for_key(key=lnbits_wallet)
|
|
||||||
logger.debug(wallet.name)
|
|
||||||
if wallet:
|
|
||||||
wallets.append(
|
|
||||||
{
|
|
||||||
"name": wallet.name,
|
|
||||||
"balance": wallet.balance_msat / 1000,
|
|
||||||
"inkey": wallet.inkey,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return wallets
|
|
||||||
|
|
||||||
|
|
||||||
async def get_placeholder_text():
|
@gerty_ext.get("/api/v1/gerty/statistics/{gerty_id}")
|
||||||
return [
|
async def api_gerty_get_statistics(gerty_id):
|
||||||
get_text_item_dict(text="Some placeholder text", x_pos=15, y_pos=10, font_size=50,gerty_type=gerty.type),
|
gerty = await get_gerty(gerty_id)
|
||||||
get_text_item_dict(text="Some placeholder text", x_pos=15, y_pos=10, font_size=50,gerty_type=gerty.type),
|
return await get_mempool_info("statistics", gerty)
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_satoshi_quotes(gerty):
|
@gerty_ext.get("/api/v1/gerty/difficulty-adjustment/{gerty_id}")
|
||||||
# Get Satoshi quotes
|
async def api_gerty_get_difficulty_adjustment(gerty_id):
|
||||||
text = []
|
gerty = await get_gerty(gerty_id)
|
||||||
quote = await api_gerty_satoshi()
|
return await get_mempool_info("difficulty_adjustment", gerty)
|
||||||
if quote:
|
|
||||||
if quote["text"]:
|
|
||||||
text.append(get_text_item_dict(text=quote["text"], font_size=15,gerty_type=gerty.type))
|
|
||||||
if quote["date"]:
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict(text="Satoshi Nakamoto - {0}".format(quote["date"]), font_size=15,gerty_type=gerty.type)
|
|
||||||
)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
# Get Exchange Value
|
@gerty_ext.get("/api/v1/gerty/tip-height/{gerty_id}")
|
||||||
async def get_exchange_rate(gerty):
|
async def api_gerty_get_tip_height(gerty_id):
|
||||||
text = []
|
gerty = await get_gerty(gerty_id)
|
||||||
if gerty.exchange != "":
|
return await get_mempool_info("tip_height", gerty)
|
||||||
try:
|
|
||||||
amount = await satoshis_amount_as_fiat(100000000, gerty.exchange)
|
|
||||||
if amount:
|
|
||||||
price = format_number(amount)
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict(
|
|
||||||
text="Current {0}/BTC price".format(gerty.exchange), font_size=15,gerty_type=gerty.type
|
|
||||||
)
|
|
||||||
)
|
|
||||||
text.append(get_text_item_dict(text=price, font_size=80,gerty_type=gerty.type))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return text
|
|
||||||
|
|
||||||
async def get_onchain_stat(stat_slug: str, gerty):
|
|
||||||
text = []
|
|
||||||
if (
|
|
||||||
stat_slug == "onchain_difficulty_epoch_progress" or
|
|
||||||
stat_slug == "onchain_difficulty_retarget_date" or
|
|
||||||
stat_slug == "onchain_difficulty_blocks_remaining" or
|
|
||||||
stat_slug == "onchain_difficulty_epoch_time_remaining"
|
|
||||||
|
|
||||||
):
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
r = await client.get(gerty.mempool_endpoint + "/api/v1/difficulty-adjustment")
|
|
||||||
if stat_slug == "onchain_difficulty_epoch_progress":
|
|
||||||
stat = round(r.json()['progressPercent'])
|
|
||||||
text.append(get_text_item_dict(text="Progress through current difficulty epoch", font_size=15,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text="{0}%".format(stat), font_size=80,gerty_type=gerty.type))
|
|
||||||
elif stat_slug == "onchain_difficulty_retarget_date":
|
|
||||||
stat = r.json()['estimatedRetargetDate']
|
|
||||||
dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M")
|
|
||||||
text.append(get_text_item_dict(text="Date of next difficulty adjustment", font_size=15,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text=dt, font_size=40,gerty_type=gerty.type))
|
|
||||||
elif stat_slug == "onchain_difficulty_blocks_remaining":
|
|
||||||
stat = r.json()['remainingBlocks']
|
|
||||||
text.append(get_text_item_dict(text="Blocks until next difficulty adjustment", font_size=15,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text="{0}".format(format_number(stat)), font_size=80,gerty_type=gerty.type))
|
|
||||||
elif stat_slug == "onchain_difficulty_epoch_time_remaining":
|
|
||||||
stat = r.json()['remainingTime']
|
|
||||||
text.append(get_text_item_dict(text="Time until next difficulty adjustment", font_size=15,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text=get_time_remaining(stat / 1000, 4), font_size=20,gerty_type=gerty.type))
|
|
||||||
return text
|
|
||||||
|
|
||||||
async def get_onchain_dashboard(gerty):
|
|
||||||
areas = []
|
|
||||||
if isinstance(gerty.mempool_endpoint, str):
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
r = await client.get(
|
|
||||||
gerty.mempool_endpoint + "/api/v1/difficulty-adjustment"
|
|
||||||
)
|
|
||||||
text = []
|
|
||||||
stat = round(r.json()["progressPercent"])
|
|
||||||
text.append(get_text_item_dict(text="Progress through epoch", font_size=12,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text="{0}%".format(stat), font_size=60,gerty_type=gerty.type))
|
|
||||||
areas.append(text)
|
|
||||||
|
|
||||||
text = []
|
|
||||||
stat = r.json()["estimatedRetargetDate"]
|
|
||||||
dt = datetime.fromtimestamp(stat / 1000).strftime("%e %b %Y at %H:%M")
|
|
||||||
text.append(get_text_item_dict(text="Date of next adjustment", font_size=12,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text=dt, font_size=20,gerty_type=gerty.type))
|
|
||||||
areas.append(text)
|
|
||||||
|
|
||||||
text = []
|
|
||||||
stat = r.json()["remainingBlocks"]
|
|
||||||
text.append(get_text_item_dict(text="Blocks until adjustment", font_size=12,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text="{0}".format(format_number(stat)), font_size=60,gerty_type=gerty.type))
|
|
||||||
areas.append(text)
|
|
||||||
|
|
||||||
text = []
|
|
||||||
stat = r.json()["remainingTime"]
|
|
||||||
text.append(get_text_item_dict(text="Time until adjustment", font_size=12,gerty_type=gerty.type))
|
|
||||||
text.append(get_text_item_dict(text=get_time_remaining(stat / 1000, 4), font_size=20,gerty_type=gerty.type))
|
|
||||||
areas.append(text)
|
|
||||||
|
|
||||||
return areas
|
|
||||||
|
|
||||||
|
|
||||||
async def get_time_remaining_next_difficulty_adjustment(gerty):
|
@gerty_ext.get("/api/v1/gerty/mempool/{gerty_id}")
|
||||||
if isinstance(gerty.mempool_endpoint, str):
|
async def api_gerty_get_mempool(gerty_id):
|
||||||
async with httpx.AsyncClient() as client:
|
gerty = await get_gerty(gerty_id)
|
||||||
r = await client.get(
|
return await get_mempool_info("mempool", gerty)
|
||||||
gerty.mempool_endpoint + "/api/v1/difficulty-adjustment"
|
|
||||||
)
|
|
||||||
stat = r.json()["remainingTime"]
|
|
||||||
time = get_time_remaining(stat / 1000, 3)
|
|
||||||
return time
|
|
||||||
|
|
||||||
|
|
||||||
async def get_block_height(gerty):
|
|
||||||
if isinstance(gerty.mempool_endpoint, str):
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
r = await client.get(gerty.mempool_endpoint + "/api/blocks/tip/height")
|
|
||||||
|
|
||||||
return r.json()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_mempool_stat(stat_slug: str, gerty):
|
|
||||||
text = []
|
|
||||||
if isinstance(gerty.mempool_endpoint, str):
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
if stat_slug == "mempool_tx_count":
|
|
||||||
r = await client.get(gerty.mempool_endpoint + "/api/mempool")
|
|
||||||
if stat_slug == "mempool_tx_count":
|
|
||||||
stat = round(r.json()["count"])
|
|
||||||
text.append(get_text_item_dict(text="Transactions in the mempool", font_size=15,gerty_type=gerty.type))
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict(text="{0}".format(format_number(stat)), font_size=80,gerty_type=gerty.type)
|
|
||||||
)
|
|
||||||
elif stat_slug == "mempool_recommended_fees":
|
|
||||||
y_offset = 60
|
|
||||||
fees = await get_mempool_recommended_fees(gerty)
|
|
||||||
pos_y = 80 + y_offset
|
|
||||||
text.append(get_text_item_dict("mempool.space", 40, 160, pos_y, gerty.type))
|
|
||||||
pos_y = 180 + y_offset
|
|
||||||
text.append(get_text_item_dict("Recommended Tx Fees", 20, 240, pos_y, gerty.type))
|
|
||||||
|
|
||||||
pos_y = 280 + y_offset
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict("{0}".format("None"), 15, 30, pos_y, gerty.type)
|
|
||||||
)
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict("{0}".format("Low"), 15, 235, pos_y, gerty.type)
|
|
||||||
)
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict("{0}".format("Medium"), 15, 460, pos_y, gerty.type)
|
|
||||||
)
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict("{0}".format("High"), 15, 750, pos_y, gerty.type)
|
|
||||||
)
|
|
||||||
|
|
||||||
pos_y = 340 + y_offset
|
|
||||||
font_size = 15
|
|
||||||
fee_append = "/vB"
|
|
||||||
fee_rate = fees["economyFee"]
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict(
|
|
||||||
text="{0} {1}{2}".format(
|
|
||||||
format_number(fee_rate),
|
|
||||||
("sat" if fee_rate == 1 else "sats"),
|
|
||||||
fee_append,
|
|
||||||
),
|
|
||||||
font_size=font_size,
|
|
||||||
x_pos=30,
|
|
||||||
y_pos=pos_y,
|
|
||||||
gerty_type=gerty.type
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
fee_rate = fees["hourFee"]
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict(
|
|
||||||
text="{0} {1}{2}".format(
|
|
||||||
format_number(fee_rate),
|
|
||||||
("sat" if fee_rate == 1 else "sats"),
|
|
||||||
fee_append,
|
|
||||||
),
|
|
||||||
font_size=font_size,
|
|
||||||
x_pos=235,
|
|
||||||
y_pos=pos_y,
|
|
||||||
gerty_type=gerty.type
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
fee_rate = fees["halfHourFee"]
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict(
|
|
||||||
text="{0} {1}{2}".format(
|
|
||||||
format_number(fee_rate),
|
|
||||||
("sat" if fee_rate == 1 else "sats"),
|
|
||||||
fee_append,
|
|
||||||
),
|
|
||||||
font_size=font_size,
|
|
||||||
x_pos=460,
|
|
||||||
y_pos=pos_y,
|
|
||||||
gerty_type=gerty.type
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
fee_rate = fees["fastestFee"]
|
|
||||||
text.append(
|
|
||||||
get_text_item_dict(
|
|
||||||
text="{0} {1}{2}".format(
|
|
||||||
format_number(fee_rate),
|
|
||||||
("sat" if fee_rate == 1 else "sats"),
|
|
||||||
fee_append,
|
|
||||||
),
|
|
||||||
font_size=font_size,
|
|
||||||
x_pos=750,
|
|
||||||
y_pos=pos_y,
|
|
||||||
gerty_type=gerty.type
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def get_date_suffix(dayNumber):
|
|
||||||
if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30:
|
|
||||||
return "th"
|
|
||||||
else:
|
|
||||||
return ["st", "nd", "rd"][dayNumber % 10 - 1]
|
|
||||||
|
|
||||||
def get_time_remaining(seconds, granularity=2):
|
|
||||||
intervals = (
|
|
||||||
# ('weeks', 604800), # 60 * 60 * 24 * 7
|
|
||||||
('days', 86400), # 60 * 60 * 24
|
|
||||||
('hours', 3600), # 60 * 60
|
|
||||||
('minutes', 60),
|
|
||||||
('seconds', 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
|
|
||||||
for name, count in intervals:
|
|
||||||
value = seconds // count
|
|
||||||
if value:
|
|
||||||
seconds -= value * count
|
|
||||||
if value == 1:
|
|
||||||
name = name.rstrip('s')
|
|
||||||
result.append("{} {}".format(round(value), name))
|
|
||||||
return ', '.join(result[:granularity])
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@
|
||||||
dense
|
dense
|
||||||
v-model.trim="formDialog.data.company_name"
|
v-model.trim="formDialog.data.company_name"
|
||||||
label="Company Name"
|
label="Company Name"
|
||||||
placeholder="LNBits Labs"
|
placeholder="LNbits Labs"
|
||||||
></q-input>
|
></q-input>
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@
|
||||||

|

|
||||||
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
|
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
|
||||||

|

|
||||||
- back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
|
- 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...)
|
- 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)\
|
- and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
|
||||||

|

|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
|
|
@ -6,11 +6,9 @@ from . import db
|
||||||
from .models import CreateJukeboxPayment, CreateJukeLinkData, Jukebox, JukeboxPayment
|
from .models import CreateJukeboxPayment, CreateJukeLinkData, Jukebox, JukeboxPayment
|
||||||
|
|
||||||
|
|
||||||
async def create_jukebox(
|
async def create_jukebox(data: CreateJukeLinkData) -> Jukebox:
|
||||||
data: CreateJukeLinkData, inkey: Optional[str] = ""
|
|
||||||
) -> Jukebox:
|
|
||||||
juke_id = urlsafe_short_hash()
|
juke_id = urlsafe_short_hash()
|
||||||
result = await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
|
INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
|
@ -36,13 +34,13 @@ async def create_jukebox(
|
||||||
|
|
||||||
|
|
||||||
async def update_jukebox(
|
async def update_jukebox(
|
||||||
data: CreateJukeLinkData, juke_id: Optional[str] = ""
|
data: Union[CreateJukeLinkData, Jukebox], juke_id: str = ""
|
||||||
) -> Optional[Jukebox]:
|
) -> Optional[Jukebox]:
|
||||||
q = ", ".join([f"{field[0]} = ?" for field in data])
|
q = ", ".join([f"{field[0]} = ?" for field in data])
|
||||||
items = [f"{field[1]}" for field in data]
|
items = [f"{field[1]}" for field in data]
|
||||||
items.append(juke_id)
|
items.append(juke_id)
|
||||||
q = q.replace("user", '"user"', 1) # hack to make user be "user"!
|
q = q.replace("user", '"user"', 1) # hack to make user be "user"!
|
||||||
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items))
|
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items,))
|
||||||
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
|
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
|
||||||
return Jukebox(**row) if row else None
|
return Jukebox(**row) if row else None
|
||||||
|
|
||||||
|
|
@ -72,7 +70,7 @@ async def delete_jukebox(juke_id: str):
|
||||||
"""
|
"""
|
||||||
DELETE FROM jukebox.jukebox WHERE id = ?
|
DELETE FROM jukebox.jukebox WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(juke_id),
|
(juke_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -80,7 +78,7 @@ async def delete_jukebox(juke_id: str):
|
||||||
|
|
||||||
|
|
||||||
async def create_jukebox_payment(data: CreateJukeboxPayment) -> JukeboxPayment:
|
async def create_jukebox_payment(data: CreateJukeboxPayment) -> JukeboxPayment:
|
||||||
result = await db.execute(
|
await db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
|
INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
from sqlite3 import Row
|
|
||||||
from typing import NamedTuple, Optional
|
|
||||||
|
|
||||||
from fastapi.param_functions import Query
|
from fastapi.param_functions import Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic.main import BaseModel
|
from pydantic.main import BaseModel
|
||||||
|
|
@ -20,19 +17,19 @@ class CreateJukeLinkData(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class Jukebox(BaseModel):
|
class Jukebox(BaseModel):
|
||||||
id: Optional[str]
|
id: str
|
||||||
user: Optional[str]
|
user: str
|
||||||
title: Optional[str]
|
title: str
|
||||||
wallet: Optional[str]
|
wallet: str
|
||||||
inkey: Optional[str]
|
inkey: str
|
||||||
sp_user: Optional[str]
|
sp_user: str
|
||||||
sp_secret: Optional[str]
|
sp_secret: str
|
||||||
sp_access_token: Optional[str]
|
sp_access_token: str
|
||||||
sp_refresh_token: Optional[str]
|
sp_refresh_token: str
|
||||||
sp_device: Optional[str]
|
sp_device: str
|
||||||
sp_playlists: Optional[str]
|
sp_playlists: str
|
||||||
price: Optional[int]
|
price: int
|
||||||
profit: Optional[int]
|
profit: int
|
||||||
|
|
||||||
|
|
||||||
class JukeboxPayment(BaseModel):
|
class JukeboxPayment(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ async def wait_for_paid_invoices():
|
||||||
|
|
||||||
|
|
||||||
async def on_invoice_paid(payment: Payment) -> None:
|
async def on_invoice_paid(payment: Payment) -> None:
|
||||||
|
if payment.extra:
|
||||||
if payment.extra.get("tag") != "jukebox":
|
if payment.extra.get("tag") != "jukebox":
|
||||||
# not a jukebox invoice
|
# not a jukebox invoice
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
@jukebox_ext.get("/", response_class=HTMLResponse)
|
@jukebox_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 jukebox_renderer().TemplateResponse(
|
return jukebox_renderer().TemplateResponse(
|
||||||
"jukebox/index.html", {"request": request, "user": user.dict()}
|
"jukebox/index.html", {"request": request, "user": user.dict()}
|
||||||
)
|
)
|
||||||
|
|
@ -31,6 +33,7 @@ async def connect_to_jukebox(request: Request, juke_id):
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist."
|
status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist."
|
||||||
)
|
)
|
||||||
devices = await api_get_jukebox_device_check(juke_id)
|
devices = await api_get_jukebox_device_check(juke_id)
|
||||||
|
deviceConnected = False
|
||||||
for device in devices["devices"]:
|
for device in devices["devices"]:
|
||||||
if device["id"] == jukebox.sp_device.split("-")[1]:
|
if device["id"] == jukebox.sp_device.split("-")[1]:
|
||||||
deviceConnected = True
|
deviceConnected = True
|
||||||
|
|
@ -48,5 +51,5 @@ async def connect_to_jukebox(request: Request, juke_id):
|
||||||
else:
|
else:
|
||||||
return jukebox_renderer().TemplateResponse(
|
return jukebox_renderer().TemplateResponse(
|
||||||
"jukebox/error.html",
|
"jukebox/error.html",
|
||||||
{"request": request, "jukebox": jukebox.jukebox(req=request)},
|
{"request": request, "jukebox": jukebox.dict()},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import json
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.param_functions import Query
|
from fastapi.param_functions import Query
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
from starlette.exceptions import HTTPException
|
from starlette.exceptions import HTTPException
|
||||||
|
|
@ -29,9 +28,7 @@ from .models import CreateJukeboxPayment, CreateJukeLinkData
|
||||||
|
|
||||||
@jukebox_ext.get("/api/v1/jukebox")
|
@jukebox_ext.get("/api/v1/jukebox")
|
||||||
async def api_get_jukeboxs(
|
async def api_get_jukeboxs(
|
||||||
req: Request,
|
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
all_wallets: bool = Query(False),
|
|
||||||
):
|
):
|
||||||
wallet_user = wallet.wallet.user
|
wallet_user = wallet.wallet.user
|
||||||
|
|
||||||
|
|
@ -53,54 +50,52 @@ async def api_check_credentials_callbac(
|
||||||
access_token: str = Query(None),
|
access_token: str = Query(None),
|
||||||
refresh_token: str = Query(None),
|
refresh_token: str = Query(None),
|
||||||
):
|
):
|
||||||
sp_code = ""
|
|
||||||
sp_access_token = ""
|
|
||||||
sp_refresh_token = ""
|
|
||||||
try:
|
|
||||||
jukebox = await get_jukebox(juke_id)
|
jukebox = await get_jukebox(juke_id)
|
||||||
except:
|
if not jukebox:
|
||||||
raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN)
|
raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN)
|
||||||
if code:
|
if code:
|
||||||
jukebox.sp_access_token = code
|
jukebox.sp_access_token = code
|
||||||
jukebox = await update_jukebox(jukebox, juke_id=juke_id)
|
await update_jukebox(jukebox, juke_id=juke_id)
|
||||||
if access_token:
|
if access_token:
|
||||||
jukebox.sp_access_token = access_token
|
jukebox.sp_access_token = access_token
|
||||||
jukebox.sp_refresh_token = refresh_token
|
jukebox.sp_refresh_token = refresh_token
|
||||||
jukebox = await update_jukebox(jukebox, juke_id=juke_id)
|
await update_jukebox(jukebox, juke_id=juke_id)
|
||||||
return "<h1>Success!</h1><h2>You can close this window</h2>"
|
return "<h1>Success!</h1><h2>You can close this window</h2>"
|
||||||
|
|
||||||
|
|
||||||
@jukebox_ext.get("/api/v1/jukebox/{juke_id}")
|
@jukebox_ext.get("/api/v1/jukebox/{juke_id}", dependencies=[Depends(require_admin_key)])
|
||||||
async def api_check_credentials_check(
|
async def api_check_credentials_check(juke_id: str = Query(None)):
|
||||||
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
|
|
||||||
):
|
|
||||||
jukebox = await get_jukebox(juke_id)
|
jukebox = await get_jukebox(juke_id)
|
||||||
return jukebox
|
return jukebox
|
||||||
|
|
||||||
|
|
||||||
@jukebox_ext.post("/api/v1/jukebox", status_code=HTTPStatus.CREATED)
|
@jukebox_ext.post(
|
||||||
|
"/api/v1/jukebox",
|
||||||
|
status_code=HTTPStatus.CREATED,
|
||||||
|
dependencies=[Depends(require_admin_key)],
|
||||||
|
)
|
||||||
@jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK)
|
@jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK)
|
||||||
async def api_create_update_jukebox(
|
async def api_create_update_jukebox(
|
||||||
data: CreateJukeLinkData,
|
data: CreateJukeLinkData, juke_id: str = Query(None)
|
||||||
juke_id: str = Query(None),
|
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
|
||||||
):
|
):
|
||||||
if juke_id:
|
if juke_id:
|
||||||
jukebox = await update_jukebox(data, juke_id=juke_id)
|
jukebox = await update_jukebox(data, juke_id=juke_id)
|
||||||
else:
|
else:
|
||||||
jukebox = await create_jukebox(data, inkey=wallet.wallet.inkey)
|
jukebox = await create_jukebox(data)
|
||||||
return jukebox
|
return jukebox
|
||||||
|
|
||||||
|
|
||||||
@jukebox_ext.delete("/api/v1/jukebox/{juke_id}")
|
@jukebox_ext.delete(
|
||||||
|
"/api/v1/jukebox/{juke_id}", dependencies=[Depends(require_admin_key)]
|
||||||
|
)
|
||||||
async def api_delete_item(
|
async def api_delete_item(
|
||||||
juke_id=None, wallet: WalletTypeInfo = Depends(require_admin_key)
|
juke_id: str = Query(None),
|
||||||
):
|
):
|
||||||
await delete_jukebox(juke_id)
|
await delete_jukebox(juke_id)
|
||||||
try:
|
# try:
|
||||||
return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
|
# return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
|
||||||
except:
|
# except:
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
|
# raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
|
||||||
|
|
||||||
|
|
||||||
################JUKEBOX ENDPOINTS##################
|
################JUKEBOX ENDPOINTS##################
|
||||||
|
|
@ -114,9 +109,8 @@ async def api_get_jukebox_song(
|
||||||
sp_playlist: str = Query(None),
|
sp_playlist: str = Query(None),
|
||||||
retry: bool = Query(False),
|
retry: bool = Query(False),
|
||||||
):
|
):
|
||||||
try:
|
|
||||||
jukebox = await get_jukebox(juke_id)
|
jukebox = await get_jukebox(juke_id)
|
||||||
except:
|
if not jukebox:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
||||||
tracks = []
|
tracks = []
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|
@ -152,14 +146,13 @@ async def api_get_jukebox_song(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except:
|
except:
|
||||||
something = None
|
pass
|
||||||
return [track for track in tracks]
|
return [track for track in tracks]
|
||||||
|
|
||||||
|
|
||||||
async def api_get_token(juke_id=None):
|
async def api_get_token(juke_id):
|
||||||
try:
|
|
||||||
jukebox = await get_jukebox(juke_id)
|
jukebox = await get_jukebox(juke_id)
|
||||||
except:
|
if not jukebox:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
|
|
@ -187,7 +180,7 @@ async def api_get_token(juke_id=None):
|
||||||
jukebox.sp_access_token = r.json()["access_token"]
|
jukebox.sp_access_token = r.json()["access_token"]
|
||||||
await update_jukebox(jukebox, juke_id=juke_id)
|
await update_jukebox(jukebox, juke_id=juke_id)
|
||||||
except:
|
except:
|
||||||
something = None
|
pass
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -198,9 +191,8 @@ async def api_get_token(juke_id=None):
|
||||||
async def api_get_jukebox_device_check(
|
async def api_get_jukebox_device_check(
|
||||||
juke_id: str = Query(None), retry: bool = Query(False)
|
juke_id: str = Query(None), retry: bool = Query(False)
|
||||||
):
|
):
|
||||||
try:
|
|
||||||
jukebox = await get_jukebox(juke_id)
|
jukebox = await get_jukebox(juke_id)
|
||||||
except:
|
if not jukebox:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
rDevice = await client.get(
|
rDevice = await client.get(
|
||||||
|
|
@ -221,7 +213,7 @@ async def api_get_jukebox_device_check(
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
|
status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return api_get_jukebox_device_check(juke_id, retry=True)
|
return await api_get_jukebox_device_check(juke_id, retry=True)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="No device connected"
|
status_code=HTTPStatus.FORBIDDEN, detail="No device connected"
|
||||||
|
|
@ -233,10 +225,8 @@ async def api_get_jukebox_device_check(
|
||||||
|
|
||||||
@jukebox_ext.get("/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}")
|
@jukebox_ext.get("/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}")
|
||||||
async def api_get_jukebox_invoice(juke_id, song_id):
|
async def api_get_jukebox_invoice(juke_id, song_id):
|
||||||
try:
|
|
||||||
jukebox = await get_jukebox(juke_id)
|
jukebox = await get_jukebox(juke_id)
|
||||||
|
if not jukebox:
|
||||||
except:
|
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||||
try:
|
try:
|
||||||
|
|
||||||
|
|
@ -266,8 +256,7 @@ async def api_get_jukebox_invoice(juke_id, song_id):
|
||||||
invoice=invoice[1], payment_hash=payment_hash, juke_id=juke_id, song_id=song_id
|
invoice=invoice[1], payment_hash=payment_hash, juke_id=juke_id, song_id=song_id
|
||||||
)
|
)
|
||||||
jukebox_payment = await create_jukebox_payment(data)
|
jukebox_payment = await create_jukebox_payment(data)
|
||||||
|
return jukebox_payment
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@jukebox_ext.get("/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}")
|
@jukebox_ext.get("/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}")
|
||||||
|
|
@ -296,13 +285,12 @@ async def api_get_jukebox_invoice_paid(
|
||||||
pay_hash: str = Query(None),
|
pay_hash: str = Query(None),
|
||||||
retry: bool = Query(False),
|
retry: bool = Query(False),
|
||||||
):
|
):
|
||||||
try:
|
|
||||||
jukebox = await get_jukebox(juke_id)
|
jukebox = await get_jukebox(juke_id)
|
||||||
except:
|
if not jukebox:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||||
await api_get_jukebox_invoice_check(pay_hash, juke_id)
|
await api_get_jukebox_invoice_check(pay_hash, juke_id)
|
||||||
jukebox_payment = await get_jukebox_payment(pay_hash)
|
jukebox_payment = await get_jukebox_payment(pay_hash)
|
||||||
if jukebox_payment.paid:
|
if jukebox_payment and jukebox_payment.paid:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
"https://api.spotify.com/v1/me/player/currently-playing?market=ES",
|
"https://api.spotify.com/v1/me/player/currently-playing?market=ES",
|
||||||
|
|
@ -407,9 +395,8 @@ async def api_get_jukebox_invoice_paid(
|
||||||
async def api_get_jukebox_currently(
|
async def api_get_jukebox_currently(
|
||||||
retry: bool = Query(False), juke_id: str = Query(None)
|
retry: bool = Query(False), juke_id: str = Query(None)
|
||||||
):
|
):
|
||||||
try:
|
|
||||||
jukebox = await get_jukebox(juke_id)
|
jukebox = await get_jukebox(juke_id)
|
||||||
except:
|
if not jukebox:
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Help DJ's and music producers conduct music livestreams
|
## 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).
|
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
|
||||||

|

|
||||||
3. For every different producer added, when adding tracks, a wallet is generated for them\
|
3. For every different producer added, when adding tracks, a wallet is generated for them\
|
||||||

|

|
||||||
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\
|
5. After all tracks and producers are added, you can start "playing" songs\
|
||||||

|

|
||||||
6. You'll see the current track playing and a green icon indicating active track also\
|
6. You'll see the current track playing and a green icon indicating active track also\
|
||||||
|
|
|
||||||
|
|
@ -60,14 +60,14 @@ async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
|
||||||
|
|
||||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||||
await update_current_track(ls.id, id)
|
await update_current_track(ls.id, id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
@livestream_ext.put("/api/v1/livestream/fee/{fee_pct}")
|
@livestream_ext.put("/api/v1/livestream/fee/{fee_pct}")
|
||||||
async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)):
|
async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)):
|
||||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||||
await update_livestream_fee(ls.id, int(fee_pct))
|
await update_livestream_fee(ls.id, int(fee_pct))
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
@livestream_ext.post("/api/v1/livestream/tracks")
|
@livestream_ext.post("/api/v1/livestream/tracks")
|
||||||
|
|
@ -93,8 +93,8 @@ async def api_add_track(
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@livestream_ext.route("/api/v1/livestream/tracks/{track_id}")
|
@livestream_ext.delete("/api/v1/livestream/tracks/{track_id}")
|
||||||
async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
|
async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
|
||||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||||
await delete_track_from_livestream(ls.id, track_id)
|
await delete_track_from_livestream(ls.id, track_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain")
|
||||||
|
|
||||||
await delete_domain(domain_id)
|
await delete_domain(domain_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
# ADDRESSES
|
# ADDRESSES
|
||||||
|
|
@ -253,4 +253,4 @@ async def api_address_delete(address_id, g: WalletTypeInfo = Depends(get_key_typ
|
||||||
)
|
)
|
||||||
|
|
||||||
await delete_address(address_id)
|
await delete_address(address_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
|
||||||
|
|
@ -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/.
|
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.crud import delete_expired_invoices, get_payments
|
||||||
from lnbits.core.services import create_invoice, pay_invoice
|
from lnbits.core.services import create_invoice, pay_invoice
|
||||||
from lnbits.decorators import WalletTypeInfo
|
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 . import lndhub_ext
|
||||||
from .decorators import check_wallet, require_admin_key
|
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")
|
@lndhub_ext.get("/ext/getinfo")
|
||||||
async def lndhub_getinfo():
|
async def lndhub_getinfo():
|
||||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="bad auth")
|
return {"alias": settings.lnbits_site_title}
|
||||||
|
|
||||||
|
|
||||||
class AuthData(BaseModel):
|
class AuthData(BaseModel):
|
||||||
|
|
@ -56,7 +56,7 @@ async def lndhub_addinvoice(
|
||||||
_, pr = await create_invoice(
|
_, pr = await create_invoice(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
amount=int(data.amt),
|
amount=int(data.amt),
|
||||||
memo=data.memo or LNBITS_SITE_TITLE,
|
memo=data.memo or settings.lnbits_site_title,
|
||||||
extra={"tag": "lndhub"},
|
extra={"tag": "lndhub"},
|
||||||
)
|
)
|
||||||
except:
|
except:
|
||||||
|
|
@ -165,6 +165,7 @@ async def lndhub_getuserinvoices(
|
||||||
limit: int = Query(20, ge=1, le=20),
|
limit: int = Query(20, ge=1, le=20),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
):
|
):
|
||||||
|
WALLET = get_wallet_class()
|
||||||
for invoice in await get_payments(
|
for invoice in await get_payments(
|
||||||
wallet_id=wallet.wallet.id,
|
wallet_id=wallet.wallet.id,
|
||||||
complete=False,
|
complete=False,
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ async def api_form_delete(form_id, wallet: WalletTypeInfo = Depends(get_key_type
|
||||||
|
|
||||||
await delete_form(form_id)
|
await delete_form(form_id)
|
||||||
|
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
||||||
|
|
||||||
#########tickets##########
|
#########tickets##########
|
||||||
|
|
@ -160,4 +160,4 @@ async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_
|
||||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
|
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
|
||||||
|
|
||||||
await delete_ticket(ticket_id)
|
await delete_ticket(ticket_id)
|
||||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
return "", HTTPStatus.NO_CONTENT
|
||||||
|
|
|
||||||
|
|
@ -105,9 +105,9 @@ async def lnurl_v1_params(
|
||||||
paymentcheck = await get_lnurlpayload(p)
|
paymentcheck = await get_lnurlpayload(p)
|
||||||
if device.device == "atm":
|
if device.device == "atm":
|
||||||
if paymentcheck:
|
if paymentcheck:
|
||||||
|
if paymentcheck.payhash != "payment_hash":
|
||||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||||
if device.device == "switch":
|
if device.device == "switch":
|
||||||
|
|
||||||
price_msat = (
|
price_msat = (
|
||||||
await fiat_amount_as_satoshis(float(profit), device.currency)
|
await fiat_amount_as_satoshis(float(profit), device.currency)
|
||||||
if device.currency != "sat"
|
if device.currency != "sat"
|
||||||
|
|
@ -177,7 +177,7 @@ async def lnurl_v1_params(
|
||||||
"callback": request.url_for(
|
"callback": request.url_for(
|
||||||
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
|
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
|
||||||
),
|
),
|
||||||
"k1": lnurldevicepayment.id,
|
"k1": p,
|
||||||
"minWithdrawable": price_msat * 1000,
|
"minWithdrawable": price_msat * 1000,
|
||||||
"maxWithdrawable": price_msat * 1000,
|
"maxWithdrawable": price_msat * 1000,
|
||||||
"defaultDescription": device.title,
|
"defaultDescription": device.title,
|
||||||
|
|
@ -227,14 +227,13 @@ async def lnurl_callback(
|
||||||
status_code=HTTPStatus.FORBIDDEN, detail="No payment request"
|
status_code=HTTPStatus.FORBIDDEN, detail="No payment request"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if lnurldevicepayment.id != k1:
|
if lnurldevicepayment.payload != k1:
|
||||||
return {"status": "ERROR", "reason": "Bad K1"}
|
return {"status": "ERROR", "reason": "Bad K1"}
|
||||||
if lnurldevicepayment.payhash != "payment_hash":
|
if lnurldevicepayment.payhash != "payment_hash":
|
||||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||||
lnurldevicepayment = await update_lnurldevicepayment(
|
lnurldevicepayment = await update_lnurldevicepayment(
|
||||||
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
|
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
|
||||||
)
|
)
|
||||||
|
|
||||||
await pay_invoice(
|
await pay_invoice(
|
||||||
wallet_id=device.wallet,
|
wallet_id=device.wallet,
|
||||||
payment_request=pr,
|
payment_request=pr,
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@ from fastapi import HTTPException
|
||||||
|
|
||||||
from lnbits import bolt11
|
from lnbits import bolt11
|
||||||
from lnbits.core.models import Payment
|
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.helpers import get_current_extension_name
|
||||||
from lnbits.tasks import register_invoice_listener
|
from lnbits.tasks import register_invoice_listener
|
||||||
|
|
||||||
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
|
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
|
||||||
from .views import updater
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_paid_invoices():
|
async def wait_for_paid_invoices():
|
||||||
|
|
@ -36,9 +35,8 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
lnurldevicepayment = await update_lnurldevicepayment(
|
lnurldevicepayment = await update_lnurldevicepayment(
|
||||||
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
|
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
|
||||||
)
|
)
|
||||||
return await updater(
|
return await websocketUpdater(
|
||||||
lnurldevicepayment.deviceid,
|
lnurldevicepayment.deviceid,
|
||||||
lnurldevicepayment.pin,
|
str(lnurldevicepayment.pin) + "-" + str(lnurldevicepayment.payload),
|
||||||
lnurldevicepayment.payload,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -157,9 +157,9 @@
|
||||||
unelevated
|
unelevated
|
||||||
color="primary"
|
color="primary"
|
||||||
size="md"
|
size="md"
|
||||||
@click="copyText(wslocation + '/lnurldevice/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
|
@click="copyText(wslocation + '/api/v1/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
|
||||||
>{% raw %}{{wslocation}}/lnurldevice/ws/{{settingsDialog.data.id}}{%
|
>{% raw %}{{wslocation}}/api/v1/ws/{{settingsDialog.data.id}}{% endraw
|
||||||
endraw %}<q-tooltip> Click to copy URL </q-tooltip>
|
%}<q-tooltip> Click to copy URL </q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
v-else
|
v-else
|
||||||
|
|
@ -487,6 +487,17 @@
|
||||||
@click="copyText(lnurlValue, 'LNURL copied to clipboard!')"
|
@click="copyText(lnurlValue, 'LNURL copied to clipboard!')"
|
||||||
>Copy LNURL</q-btn
|
>Copy LNURL</q-btn
|
||||||
>
|
>
|
||||||
|
<q-chip
|
||||||
|
v-if="websocketMessage == 'WebSocket NOT supported by your Browser!' || websocketMessage == 'Connection closed'"
|
||||||
|
clickable
|
||||||
|
color="red"
|
||||||
|
text-color="white"
|
||||||
|
icon="error"
|
||||||
|
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
|
||||||
|
>
|
||||||
|
<q-chip v-else clickable color="green" text-color="white" icon="check"
|
||||||
|
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
|
||||||
|
>
|
||||||
<br />
|
<br />
|
||||||
<div class="row q-mt-lg q-gutter-sm">
|
<div class="row q-mt-lg q-gutter-sm">
|
||||||
<q-btn
|
<q-btn
|
||||||
|
|
@ -534,6 +545,7 @@
|
||||||
filter: '',
|
filter: '',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
lnurlValue: '',
|
lnurlValue: '',
|
||||||
|
websocketMessage: '',
|
||||||
switches: 0,
|
switches: 0,
|
||||||
lnurldeviceLinks: [],
|
lnurldeviceLinks: [],
|
||||||
lnurldeviceLinksObj: [],
|
lnurldeviceLinksObj: [],
|
||||||
|
|
@ -622,6 +634,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
wsMessage: function () {
|
||||||
|
return this.websocketMessage
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
openQrCodeDialog: function (lnurldevice_id) {
|
openQrCodeDialog: function (lnurldevice_id) {
|
||||||
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
|
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
|
||||||
|
|
@ -631,11 +648,17 @@
|
||||||
this.qrCodeDialog.data = _.clone(lnurldevice)
|
this.qrCodeDialog.data = _.clone(lnurldevice)
|
||||||
this.qrCodeDialog.data.url =
|
this.qrCodeDialog.data.url =
|
||||||
window.location.protocol + '//' + window.location.host
|
window.location.protocol + '//' + window.location.host
|
||||||
this.lnurlValueFetch(this.qrCodeDialog.data.switches[0][3])
|
this.lnurlValueFetch(
|
||||||
|
this.qrCodeDialog.data.switches[0][3],
|
||||||
|
this.qrCodeDialog.data.id
|
||||||
|
)
|
||||||
this.qrCodeDialog.show = true
|
this.qrCodeDialog.show = true
|
||||||
},
|
},
|
||||||
lnurlValueFetch: function (lnurl) {
|
lnurlValueFetch: function (lnurl, switchId) {
|
||||||
this.lnurlValue = lnurl
|
this.lnurlValue = lnurl
|
||||||
|
this.websocketConnector(
|
||||||
|
'wss://' + window.location.host + '/api/v1/ws/' + switchId
|
||||||
|
)
|
||||||
},
|
},
|
||||||
addSwitch: function () {
|
addSwitch: function () {
|
||||||
var self = this
|
var self = this
|
||||||
|
|
@ -797,6 +820,25 @@
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
websocketConnector: function (websocketUrl) {
|
||||||
|
if ('WebSocket' in window) {
|
||||||
|
self = this
|
||||||
|
var ws = new WebSocket(websocketUrl)
|
||||||
|
self.updateWsMessage('Websocket connected')
|
||||||
|
ws.onmessage = function (evt) {
|
||||||
|
var received_msg = evt.data
|
||||||
|
self.updateWsMessage('Message recieved: ' + received_msg)
|
||||||
|
}
|
||||||
|
ws.onclose = function () {
|
||||||
|
self.updateWsMessage('Connection closed')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.updateWsMessage('WebSocket NOT supported by your Browser!')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateWsMessage: function (message) {
|
||||||
|
this.websocketMessage = message
|
||||||
|
},
|
||||||
clearFormDialoglnurldevice() {
|
clearFormDialoglnurldevice() {
|
||||||
this.formDialoglnurldevice.data = {
|
this.formDialoglnurldevice.data = {
|
||||||
lnurl_toggle: false,
|
lnurl_toggle: false,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from http import HTTPStatus
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
import pyqrcode
|
import pyqrcode
|
||||||
from fastapi import Request, WebSocket, WebSocketDisconnect
|
from fastapi import Request
|
||||||
from fastapi.param_functions import Query
|
from fastapi.param_functions import Query
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
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."
|
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
|
||||||
)
|
)
|
||||||
return lnurldevice.lnurl(request)
|
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
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,15 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
||||||
served_meta,
|
served_meta,
|
||||||
served_pr,
|
served_pr,
|
||||||
webhook_url,
|
webhook_url,
|
||||||
|
webhook_headers,
|
||||||
|
webhook_body,
|
||||||
success_text,
|
success_text,
|
||||||
success_url,
|
success_url,
|
||||||
comment_chars,
|
comment_chars,
|
||||||
currency,
|
currency,
|
||||||
fiat_base_multiplier
|
fiat_base_multiplier
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
{returning}
|
{returning}
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -36,6 +38,8 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
||||||
data.min,
|
data.min,
|
||||||
data.max,
|
data.max,
|
||||||
data.webhook_url,
|
data.webhook_url,
|
||||||
|
data.webhook_headers,
|
||||||
|
data.webhook_body,
|
||||||
data.success_text,
|
data.success_text,
|
||||||
data.success_url,
|
data.success_url,
|
||||||
data.comment_chars,
|
data.comment_chars,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ async def m001_initial(db):
|
||||||
id {db.serial_primary_key},
|
id {db.serial_primary_key},
|
||||||
wallet TEXT NOT NULL,
|
wallet TEXT NOT NULL,
|
||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
amount INTEGER NOT NULL,
|
amount {db.big_int} NOT NULL,
|
||||||
served_meta INTEGER NOT NULL,
|
served_meta INTEGER NOT NULL,
|
||||||
served_pr INTEGER NOT NULL
|
served_pr INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
@ -60,3 +60,11 @@ async def m004_fiat_base_multiplier(db):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m005_webhook_headers_and_body(db):
|
||||||
|
"""
|
||||||
|
Add headers and body to webhooks
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_headers TEXT;")
|
||||||
|
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_body TEXT;")
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ class CreatePayLinkData(BaseModel):
|
||||||
currency: str = Query(None)
|
currency: str = Query(None)
|
||||||
comment_chars: int = Query(0, ge=0, lt=800)
|
comment_chars: int = Query(0, ge=0, lt=800)
|
||||||
webhook_url: str = Query(None)
|
webhook_url: str = Query(None)
|
||||||
|
webhook_headers: str = Query(None)
|
||||||
|
webhook_body: str = Query(None)
|
||||||
success_text: str = Query(None)
|
success_text: str = Query(None)
|
||||||
success_url: str = Query(None)
|
success_url: str = Query(None)
|
||||||
fiat_base_multiplier: int = Query(100, ge=1)
|
fiat_base_multiplier: int = Query(100, ge=1)
|
||||||
|
|
@ -31,6 +33,8 @@ class PayLink(BaseModel):
|
||||||
served_meta: int
|
served_meta: int
|
||||||
served_pr: int
|
served_pr: int
|
||||||
webhook_url: Optional[str]
|
webhook_url: Optional[str]
|
||||||
|
webhook_headers: Optional[str]
|
||||||
|
webhook_body: Optional[str]
|
||||||
success_text: Optional[str]
|
success_text: Optional[str]
|
||||||
success_url: Optional[str]
|
success_url: Optional[str]
|
||||||
currency: Optional[str]
|
currency: Optional[str]
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,22 @@ async def on_invoice_paid(payment: Payment) -> None:
|
||||||
if pay_link and pay_link.webhook_url:
|
if pay_link and pay_link.webhook_url:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
try:
|
try:
|
||||||
r = await client.post(
|
kwargs = {
|
||||||
pay_link.webhook_url,
|
"json": {
|
||||||
json={
|
|
||||||
"payment_hash": payment.payment_hash,
|
"payment_hash": payment.payment_hash,
|
||||||
"payment_request": payment.bolt11,
|
"payment_request": payment.bolt11,
|
||||||
"amount": payment.amount,
|
"amount": payment.amount,
|
||||||
"comment": payment.extra.get("comment"),
|
"comment": payment.extra.get("comment"),
|
||||||
"lnurlp": pay_link.id,
|
"lnurlp": pay_link.id,
|
||||||
},
|
},
|
||||||
timeout=40,
|
"timeout": 40,
|
||||||
)
|
}
|
||||||
|
if pay_link.webhook_body:
|
||||||
|
kwargs["json"]["body"] = json.loads(pay_link.webhook_body)
|
||||||
|
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)
|
await mark_webhook_sent(payment, r.status_code)
|
||||||
except (httpx.ConnectError, httpx.RequestError):
|
except (httpx.ConnectError, httpx.RequestError):
|
||||||
await mark_webhook_sent(payment, -1)
|
await mark_webhook_sent(payment, -1)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue