Merge branch 'main' into diagon-alley
This commit is contained in:
commit
7037793369
106 changed files with 2970 additions and 1136 deletions
24
.env.example
24
.env.example
|
|
@ -11,6 +11,9 @@ LNBITS_ADMIN_USERS=""
|
|||
LNBITS_ADMIN_EXTENSIONS="nostradmin"
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
|
||||
LNBITS_AD_SPACE="" # csv ad image filepaths or urls, extensions can choose to honor
|
||||
LNBITS_HIDE_API=false # Hides wallet api, extensions can choose to honor
|
||||
|
||||
# Disable extensions for all users, use "all" to disable all extensions
|
||||
LNBITS_DISABLED_EXTENSIONS="amilk"
|
||||
|
||||
|
|
@ -29,11 +32,12 @@ LNBITS_SERVICE_FEE="0.0"
|
|||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||
# Choose from mint, flamingo, salvador, autumn, monochrome, classic
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, mint, autumn, monochrome, salvador"
|
||||
# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC),
|
||||
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet,
|
||||
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
# just so you can see the UI before dealing with this file.
|
||||
|
|
@ -50,14 +54,6 @@ CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
|||
LNBITS_ENDPOINT=https://legend.lnbits.com
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
|
||||
# LndWallet
|
||||
LND_GRPC_ENDPOINT=127.0.0.1
|
||||
LND_GRPC_PORT=11009
|
||||
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# LND_GRPC_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
# LndRestWallet
|
||||
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
||||
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
|
|
@ -83,3 +79,7 @@ OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
|||
# FakeWallet
|
||||
FAKE_WALLET_SECRET="ToTheMoon1"
|
||||
LNBITS_DENOMINATION=sats
|
||||
|
||||
# EclairWallet
|
||||
ECLAIR_URL=http://127.0.0.1:8283
|
||||
ECLAIR_PASS=eclairpw
|
||||
1
.github/workflows/mypy.yml
vendored
1
.github/workflows/mypy.yml
vendored
|
|
@ -9,4 +9,5 @@ jobs:
|
|||
- uses: actions/checkout@v1
|
||||
- uses: jpetrucciani/mypy-check@master
|
||||
with:
|
||||
mypy_flags: '--install-types --non-interactive'
|
||||
path: lnbits
|
||||
|
|
|
|||
58
.github/workflows/on-push.yml
vendored
58
.github/workflows/on-push.yml
vendored
|
|
@ -1,58 +0,0 @@
|
|||
name: Docker build on push
|
||||
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
name: Build and push lnbits image
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
id: qemu
|
||||
|
||||
- name: Setup Docker buildx action
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: buildx
|
||||
|
||||
- name: Show available Docker buildx platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
id: cache
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Run Docker buildx against commit hash
|
||||
run: |
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:${GITHUB_SHA:0:7} \
|
||||
--output "type=registry" ./
|
||||
|
||||
- name: Run Docker buildx against latest
|
||||
run: |
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:latest \
|
||||
--output "type=registry" ./
|
||||
68
.github/workflows/on-tag.yml
vendored
Normal file
68
.github/workflows/on-tag.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
name: Build and push Docker image on tag
|
||||
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+.[0-9]+"
|
||||
- "[0-9]+.[0-9]+.[0-9]+-*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
name: Build and push lnbits image
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Import environment variables
|
||||
id: import-env
|
||||
shell: bash
|
||||
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||
|
||||
- name: Show set environment variables
|
||||
run: |
|
||||
printf " TAG: %s\n" "$TAG"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
id: qemu
|
||||
|
||||
- name: Setup Docker buildx action
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: buildx
|
||||
|
||||
- name: Show available Docker buildx platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
id: cache
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Run Docker buildx against tag
|
||||
run: |
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:${TAG} \
|
||||
--output "type=registry" ./
|
||||
|
||||
- name: Run Docker buildx against latest
|
||||
run: |
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits-legend:latest \
|
||||
--output "type=registry" ./
|
||||
|
|
@ -8,8 +8,9 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
|||
|
||||
# Install build deps
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y --no-install-recommends build-essential
|
||||
RUN apt-get install -y --no-install-recommends build-essential pkg-config
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN pip install wheel
|
||||
|
||||
# Install runtime deps
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
|
|
@ -36,6 +37,9 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
|||
WORKDIR /app
|
||||
COPY --chown=1000:1000 lnbits /app/lnbits
|
||||
|
||||
ENV LNBITS_PORT="5000"
|
||||
ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["uvicorn", "lnbits.__main__:app", "--port", "5000", "--host", "0.0.0.0"]
|
||||
CMD ["sh", "-c", "uvicorn lnbits.__main__:app --port $LNBITS_PORT --host $LNBITS_HOST"]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ LNbits
|
|||
|
||||
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
|
||||
|
||||
(LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me)
|
||||
|
||||
Use [lnbits.com](https://lnbits.com), or run your own LNbits server!
|
||||
|
||||
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
|
||||
|
|
|
|||
1
conv.py
1
conv.py
|
|
@ -1,6 +1,7 @@
|
|||
import psycopg2
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# Python script to migrate an LNbits SQLite DB to Postgres
|
||||
# All credits to @Fritz446 for the awesome work
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ LNbits uses [Pipenv][pipenv] to manage Python packages.
|
|||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
|
||||
sudo apt-get install pipenv
|
||||
pipenv shell
|
||||
# pipenv --python 3.9 shell (if you wish to use a version of Python higher than 3.7)
|
||||
pipenv install --dev
|
||||
|
|
@ -19,6 +21,9 @@ pipenv install --dev
|
|||
|
||||
# If any of the modules fails to install, try checking and upgrading your setupTool module
|
||||
# pip install -U setuptools
|
||||
|
||||
# install libffi/libpq in case "pipenv install" fails
|
||||
# sudo apt-get install -y libffi-dev libpq-dev
|
||||
```
|
||||
## Running the server
|
||||
|
||||
|
|
@ -41,4 +46,7 @@ E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `
|
|||
|
||||
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
**Note**: We reccomend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy, if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
|
||||
**Notes**:
|
||||
|
||||
* We reccomend using <a href="https://caddyserver.com/docs/install#debian-ubuntu-raspbian">Caddy</a> for a reverse-proxy if you want to serve your install through a domain, alternatively you can use [ngrok](https://ngrok.com/).
|
||||
* <a href="https://linuxize.com/post/how-to-use-linux-screen/#starting-linux-screen">Screen</a> works well if you want LNbits to continue running when you close your terminal session.
|
||||
|
|
|
|||
|
|
@ -49,17 +49,20 @@ You might also need to install additional packages or perform additional setup s
|
|||
## Important note
|
||||
If you already have LNbits installed and running, on an SQLite database, we **HIGHLY** recommend you migrate to postgres!
|
||||
|
||||
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user, check the guide above.
|
||||
There's a script included that can do the migration easy. You should have Postgres already installed and there should be a password for the user, check the guide above. Additionally, your lnbits instance should run once on postgres to implement the database schema before the migration works:
|
||||
|
||||
```sh
|
||||
# STOP LNbits
|
||||
# on the LNBits folder, locate and edit 'conv.py' with the relevant credentials
|
||||
python3 conv.py
|
||||
|
||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||
# postgres://<user>:<password>@<host>/<database> - alter line bellow with your user, password and db name
|
||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||
# save and exit
|
||||
|
||||
# START LNbits
|
||||
# STOP LNbits
|
||||
# on the LNBits folder, locate and edit 'conv.py' with the relevant credentials
|
||||
python3 conv.py
|
||||
```
|
||||
|
||||
Hopefully, everything works and get migrated... Launch LNbits again and check if everything is working properly.
|
||||
|
|
@ -78,17 +81,23 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo
|
|||
|
||||
[Unit]
|
||||
Description=LNbits
|
||||
#Wants=lnd.service # you can uncomment these lines if you know what you're doing
|
||||
#After=lnd.service # it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||
# you can uncomment these lines if you know what you're doing
|
||||
# it will make sure that lnbits starts after lnd (replace with your own backend service)
|
||||
#Wants=lnd.service
|
||||
#After=lnd.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/home/bitcoin/lnbits # replace with the absolute path of your lnbits installation
|
||||
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # same here
|
||||
User=bitcoin # replace with the user that you're running lnbits on
|
||||
# replace with the absolute path of your lnbits installation
|
||||
WorkingDirectory=/home/bitcoin/lnbits
|
||||
# same here
|
||||
ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000
|
||||
# replace with the user that you're running lnbits on
|
||||
User=bitcoin
|
||||
Restart=always
|
||||
TimeoutSec=120
|
||||
RestartSec=30
|
||||
Environment=PYTHONUNBUFFERED=1 # this makes sure that you receive logs in real time
|
||||
# this makes sure that you receive logs in real time
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ To encrypt your macaroon, run `./venv/bin/python lnbits/wallets/macaroon/macaroo
|
|||
### LND (REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: ip_address
|
||||
- `LND_REST_ENDPOINT`: http://10.147.17.230:8080/
|
||||
- `LND_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_REST_MACAROON`: /file/path/admin.macaroon or Bech64/Hex
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ import importlib
|
|||
import sys
|
||||
import traceback
|
||||
import warnings
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
import lnbits.settings
|
||||
|
|
@ -58,15 +60,19 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
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 "text/html" in request.headers["accept"]:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html",
|
||||
{"request": request, "err": f"`{exc.errors()}` is not a valid UUID."},
|
||||
{"request": request, "err": f"{exc.errors()} is not a valid UUID."},
|
||||
)
|
||||
|
||||
# return HTMLResponse(
|
||||
# status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
# content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}),
|
||||
# )
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": exc.errors()},
|
||||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
# app.add_middleware(ASGIProxyFix)
|
||||
|
|
@ -84,15 +90,16 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
def check_funding_source(app: FastAPI) -> None:
|
||||
@app.on_event("startup")
|
||||
async def check_wallet_status():
|
||||
while True:
|
||||
error_message, balance = await WALLET.status()
|
||||
if error_message:
|
||||
if not error_message:
|
||||
break
|
||||
warnings.warn(
|
||||
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
|
||||
sys.exit(4)
|
||||
else:
|
||||
print("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
print(
|
||||
f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat."
|
||||
)
|
||||
|
|
@ -167,9 +174,17 @@ def register_exception_handlers(app: FastAPI):
|
|||
@app.exception_handler(Exception)
|
||||
async def basic_error(request: Request, err):
|
||||
print("handled error", traceback.format_exc())
|
||||
print("ERROR:", err)
|
||||
etype, _, tb = sys.exc_info()
|
||||
traceback.print_exception(etype, err, tb)
|
||||
exc = traceback.format_exc()
|
||||
|
||||
if "text/html" in request.headers["accept"]:
|
||||
return template_renderer().TemplateResponse(
|
||||
"error.html", {"request": request, "err": err}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=HTTPStatus.NO_CONTENT,
|
||||
content={"detail": err},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -348,9 +348,9 @@ def _trim_to_bytes(barr):
|
|||
|
||||
def _readable_scid(short_channel_id: int) -> str:
|
||||
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
||||
blockheight=((short_channel_id >> 40) & 0xffffff),
|
||||
transactionindex=((short_channel_id >> 16) & 0xffffff),
|
||||
outputindex=(short_channel_id & 0xffff),
|
||||
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
|
||||
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
|
||||
outputindex=(short_channel_id & 0xFFFF),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -180,13 +180,18 @@ async def get_wallet_for_key(
|
|||
|
||||
|
||||
async def get_standalone_payment(
|
||||
checking_id_or_hash: str, conn: Optional[Connection] = None
|
||||
checking_id_or_hash: str,
|
||||
conn: Optional[Connection] = None,
|
||||
incoming: Optional[bool] = False,
|
||||
) -> Optional[Payment]:
|
||||
clause: str = "checking_id = ? OR hash = ?"
|
||||
if incoming:
|
||||
clause = f"({clause}) AND amount > 0"
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
f"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE checking_id = ? OR hash = ?
|
||||
WHERE {clause}
|
||||
LIMIT 1
|
||||
""",
|
||||
(checking_id_or_hash, checking_id_or_hash),
|
||||
|
|
|
|||
|
|
@ -85,18 +85,17 @@ async def pay_invoice(
|
|||
description: str = "",
|
||||
conn: Optional[Connection] = None,
|
||||
) -> str:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
fee_reserve_msat = fee_reserve(invoice.amount_msat)
|
||||
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
|
||||
temp_id = f"temp_{urlsafe_short_hash()}"
|
||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
if invoice.amount_msat == 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
|
||||
# put all parameters that don't change here
|
||||
PaymentKwargs = TypedDict(
|
||||
"PaymentKwargs",
|
||||
|
|
@ -134,26 +133,20 @@ async def pay_invoice(
|
|||
# the balance is enough in the next step
|
||||
await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve(invoice.amount_msat),
|
||||
fee=-fee_reserve_msat,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
|
||||
# do the balance check if internal payment
|
||||
if internal_checking_id:
|
||||
# do the balance check
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet
|
||||
if wallet.balance_msat < 0:
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
# do the balance check if external payment
|
||||
else:
|
||||
if invoice.amount_msat > wallet.balance_msat - (
|
||||
wallet.balance_msat / 100 * 2
|
||||
):
|
||||
raise PermissionError(
|
||||
"LNbits requires you keep at least 2% reserve to cover potential routing fees."
|
||||
if not internal_checking_id and wallet.balance_msat > -fee_reserve_msat:
|
||||
raise PaymentFailure(
|
||||
f"You must reserve at least 1% ({round(fee_reserve_msat/1000)} sat) to cover potential routing fees."
|
||||
)
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
if internal_checking_id:
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
|
|
@ -171,7 +164,9 @@ async def pay_invoice(
|
|||
await internal_invoice_queue.put(internal_checking_id)
|
||||
else:
|
||||
# actually pay the external invoice
|
||||
payment: PaymentResponse = await WALLET.pay_invoice(payment_request)
|
||||
payment: PaymentResponse = await WALLET.pay_invoice(
|
||||
payment_request, fee_reserve_msat
|
||||
)
|
||||
if payment.checking_id:
|
||||
async with db.connect() as conn:
|
||||
await create_payment(
|
||||
|
|
@ -340,5 +335,6 @@ async def check_invoice_status(
|
|||
return status
|
||||
|
||||
|
||||
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
return max(1000, int(amount_msat * 0.01))
|
||||
return max(2000, int(amount_msat * 0.01))
|
||||
|
|
|
|||
|
|
@ -364,12 +364,12 @@ new Vue({
|
|||
},
|
||||
decodeRequest: function () {
|
||||
this.parse.show = true
|
||||
|
||||
let req = this.parse.data.request.toLowerCase()
|
||||
if (this.parse.data.request.startsWith('lightning:')) {
|
||||
this.parse.data.request = this.parse.data.request.slice(10)
|
||||
} else if (this.parse.data.request.startsWith('lnurl:')) {
|
||||
this.parse.data.request = this.parse.data.request.slice(6)
|
||||
} else if (this.parse.data.request.indexOf('lightning=lnurl1') !== -1) {
|
||||
} else if (req.indexOf('lightning=lnurl1') !== -1) {
|
||||
this.parse.data.request = this.parse.data.request
|
||||
.split('lightning=')[1]
|
||||
.split('&')[0]
|
||||
|
|
@ -618,10 +618,10 @@ new Vue({
|
|||
},
|
||||
updateWalletName: function () {
|
||||
let newName = this.newName
|
||||
let adminkey = this.g.wallet.adminkey
|
||||
if (!newName || !newName.length) return
|
||||
// let data = {name: newName}
|
||||
LNbits.api
|
||||
.request('PUT', '/api/v1/wallet/' + newName, this.g.wallet.inkey, {})
|
||||
.request('PUT', '/api/v1/wallet/' + newName, adminkey, {})
|
||||
.then(res => {
|
||||
this.newName = ''
|
||||
this.$q.notify({
|
||||
|
|
@ -691,10 +691,7 @@ new Vue({
|
|||
},
|
||||
mounted: function () {
|
||||
// show disclaimer
|
||||
if (
|
||||
this.$refs.disclaimer &&
|
||||
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
|
||||
) {
|
||||
if (!this.$q.localStorage.getItem('lnbits.disclaimerShown')) {
|
||||
this.disclaimerDialog.show = true
|
||||
this.$q.localStorage.set('lnbits.disclaimerShown', true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"out": false, "amount": <int>, "memo": <string>}</code
|
||||
>{"out": false, "amount": <int>, "memo": <string>, "unit": <string>, "webhook": <url:string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
|
||||
"amount": <int>, "memo": <string>, "webhook":
|
||||
<url:string>}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
|
||||
<url:string>, "unit": <string>}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H
|
||||
"Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
color="primary"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}"
|
||||
href="{{ url_for('core.lnurlwallet') }}?lightning={{ lnurl }}"
|
||||
>
|
||||
Press to claim bitcoin
|
||||
</q-btn>
|
||||
|
|
|
|||
|
|
@ -273,6 +273,10 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% if HIDE_API %}
|
||||
<div class="col-12 col-md-4 q-gutter-y-md">
|
||||
{% else %}
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
|
@ -288,12 +292,16 @@
|
|||
<q-separator></q-separator>
|
||||
|
||||
{% if wallet.lnurlwithdraw_full %}
|
||||
<q-expansion-item group="extras" icon="crop_free" label="Drain Funds">
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="crop_free"
|
||||
label="Drain Funds"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section class="text-center">
|
||||
<p>
|
||||
This is an LNURL-withdraw QR code for slurping everything from
|
||||
this wallet. Do not share with anyone.
|
||||
This is an LNURL-withdraw QR code for slurping everything
|
||||
from this wallet. Do not share with anyone.
|
||||
</p>
|
||||
<a href="lightning:{{wallet.lnurlwithdraw_full}}">
|
||||
<qrcode
|
||||
|
|
@ -303,8 +311,8 @@
|
|||
</a>
|
||||
<p>
|
||||
It is compatible with <code>balanceCheck</code> and
|
||||
<code>balanceNotify</code> so your wallet may keep pulling the
|
||||
funds continuously from here after the first withdraw.
|
||||
<code>balanceNotify</code> so your wallet may keep pulling
|
||||
the funds continuously from here after the first withdraw.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -378,6 +386,13 @@
|
|||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
|
||||
ADS.split(';') %}
|
||||
<q-card>
|
||||
<a href="{{ AD[0] }}"
|
||||
><img width="100%" src="{{ AD[1] }}"
|
||||
/></a> </q-card
|
||||
>{% endfor %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -417,9 +432,11 @@
|
|||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
type="number"
|
||||
label="Amount ({{LNBITS_DENOMINATION}}) *"
|
||||
:step="receive.unit != 'sat' ? '0.001' : '1'"
|
||||
:label="'Amount (' + receive.unit + ') *'"
|
||||
:mask="receive.unit != 'sat' ? '#.##' : '#'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="receive.unit != 'sat' ? '0.01' : '1'"
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
|
|
@ -437,7 +454,7 @@
|
|||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="receive.data.memo == null || receive.data.amount == null || receive.data.amount <= 0"
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
|
|
@ -445,7 +462,9 @@
|
|||
</span>
|
||||
<span v-else> Create invoice </span>
|
||||
</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
<q-spinner
|
||||
v-if="receive.status == 'loading'"
|
||||
|
|
@ -480,8 +499,8 @@
|
|||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div v-if="parse.invoice">
|
||||
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none">
|
||||
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",", ""))
|
||||
/ 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",",
|
||||
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
</h6>
|
||||
<h6 v-else class="q-my-none">
|
||||
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
|
||||
|
|
@ -495,13 +514,17 @@
|
|||
{% endraw %}
|
||||
<div v-if="canPay" class="row q-mt-lg">
|
||||
<q-btn unelevated color="primary" @click="payInvoice">Pay</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
<div v-else class="row q-mt-lg">
|
||||
<q-btn unelevated disabled color="yellow" text-color="black"
|
||||
>Not enough funds!</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlauth">
|
||||
|
|
@ -514,8 +537,8 @@
|
|||
<p>
|
||||
For every website and for every LNbits wallet, a new keypair will be
|
||||
deterministically generated so your identity can't be tied to your
|
||||
LNbits wallet or linked across websites. No other data will be shared
|
||||
with {{ parse.lnurlauth.domain }}.
|
||||
LNbits wallet or linked across websites. No other data will be
|
||||
shared with {{ parse.lnurlauth.domain }}.
|
||||
</p>
|
||||
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||
<p class="q-mx-xl">
|
||||
|
|
@ -535,7 +558,8 @@
|
|||
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
||||
parse.lnurlpay.maxSendable | msatoshiFormat }} {{LNBITS_DENOMINATION}}
|
||||
parse.lnurlpay.maxSendable | msatoshiFormat }}
|
||||
{{LNBITS_DENOMINATION}}
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
|
|
@ -645,7 +669,10 @@
|
|||
<q-dialog v-model="parse.camera.show">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<div class="text-center q-mb-lg">
|
||||
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
||||
|
|
@ -679,11 +706,10 @@
|
|||
|
||||
<q-tab icon="photo_camera" label="Scan" @click="showCamera"> </q-tab>
|
||||
</q-tabs>
|
||||
{% if service_fee > 0 %}
|
||||
<div ref="disclaimer"></div>
|
||||
|
||||
<q-dialog v-model="disclaimerDialog.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-deep-purple">Warning</h6>
|
||||
<h6 class="q-my-md text-primary">Warning</h6>
|
||||
<p>
|
||||
Login functionality to be released in v0.2, for now,
|
||||
<strong
|
||||
|
|
@ -693,10 +719,10 @@
|
|||
</p>
|
||||
<p>
|
||||
This service is in BETA, and we hold no responsibility for people losing
|
||||
access to funds. To encourage you to run your own LNbits installation, any
|
||||
balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will
|
||||
incur a charge of <strong>{{ service_fee }}% service fee</strong> per
|
||||
week.
|
||||
access to funds. {% if service_fee > 0 %} To encourage you to run your
|
||||
own LNbits installation, any balance on {% raw %}{{
|
||||
disclaimerDialog.location.host }}{% endraw %} will incur a charge of
|
||||
<strong>{{ service_fee }}% service fee</strong> per week. {% endif %}
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
|
|
@ -711,4 +737,5 @@
|
|||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endif %} {% endblock %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Union
|
|||
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Query, Request
|
||||
from fastapi import Header, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.param_functions import Depends
|
||||
from fastapi.params import Body
|
||||
|
|
@ -23,9 +23,11 @@ from lnbits.decorators import (
|
|||
WalletInvoiceKeyChecker,
|
||||
WalletTypeInfo,
|
||||
get_key_type,
|
||||
require_admin_key,
|
||||
)
|
||||
from lnbits.helpers import url_for
|
||||
from lnbits.helpers import url_for, urlsafe_short_hash
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS, LNBITS_SITE_TITLE
|
||||
from lnbits.utils.exchange_rates import (
|
||||
currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
|
|
@ -34,13 +36,14 @@ from lnbits.utils.exchange_rates import (
|
|||
|
||||
from .. import core_app, db
|
||||
from ..crud import (
|
||||
create_payment,
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
save_balance_check,
|
||||
update_wallet,
|
||||
create_payment,
|
||||
get_wallet,
|
||||
get_wallet_for_key,
|
||||
save_balance_check,
|
||||
update_payment_status,
|
||||
update_wallet,
|
||||
)
|
||||
from ..services import (
|
||||
InvoiceFailure,
|
||||
|
|
@ -51,8 +54,6 @@ from ..services import (
|
|||
perform_lnurlauth,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
from lnbits.settings import LNBITS_ADMIN_USERS
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
|
||||
@core_app.get("/api/v1/wallet")
|
||||
|
|
@ -98,7 +99,7 @@ async def api_update_balance(
|
|||
|
||||
@core_app.put("/api/v1/wallet/{new_name}")
|
||||
async def api_update_wallet(
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(WalletAdminKeyChecker())
|
||||
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
await update_wallet(wallet.wallet.id, new_name)
|
||||
return {
|
||||
|
|
@ -123,8 +124,8 @@ async def api_payments(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
|
||||
class CreateInvoiceData(BaseModel):
|
||||
out: Optional[bool] = True
|
||||
amount: int = Query(None, ge=1)
|
||||
memo: str = None
|
||||
amount: float = Query(None, ge=0)
|
||||
memo: Optional[str] = None
|
||||
unit: Optional[str] = "sat"
|
||||
description_hash: Optional[str] = None
|
||||
lnurl_callback: Optional[str] = None
|
||||
|
|
@ -140,9 +141,9 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
|||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
memo = data.memo
|
||||
memo = data.memo or LNBITS_SITE_TITLE
|
||||
if data.unit == "sat":
|
||||
amount = data.amount
|
||||
amount = int(data.amount)
|
||||
else:
|
||||
price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)
|
||||
amount = price_in_sats
|
||||
|
|
@ -363,7 +364,13 @@ async def api_payments_sse(
|
|||
|
||||
|
||||
@core_app.get("/api/v1/payments/{payment_hash}")
|
||||
async def api_payment(payment_hash):
|
||||
async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
|
||||
wallet = None
|
||||
try:
|
||||
if X_Api_Key.extra:
|
||||
print("No key")
|
||||
except:
|
||||
wallet = await get_wallet_for_key(X_Api_Key)
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
await check_invoice_status(payment.wallet_id, payment_hash)
|
||||
payment = await get_standalone_payment(payment_hash)
|
||||
|
|
@ -372,13 +379,23 @@ async def api_payment(payment_hash):
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
|
||||
)
|
||||
elif not payment.pending:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": True, "preimage": payment.preimage, "details": payment}
|
||||
return {"paid": True, "preimage": payment.preimage}
|
||||
|
||||
try:
|
||||
await payment.check_pending()
|
||||
except Exception:
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {"paid": False, "details": payment}
|
||||
return {"paid": False}
|
||||
|
||||
if wallet and wallet.id == payment.wallet_id:
|
||||
return {
|
||||
"paid": not payment.pending,
|
||||
"preimage": payment.preimage,
|
||||
"details": payment,
|
||||
}
|
||||
return {"paid": not payment.pending, "preimage": payment.preimage}
|
||||
|
||||
|
||||
|
|
@ -500,14 +517,19 @@ async def api_lnurlscan(code: str):
|
|||
return params
|
||||
|
||||
|
||||
class DecodePayment(BaseModel):
|
||||
data: str
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/decode")
|
||||
async def api_payments_decode(data: str = Query(None)):
|
||||
async def api_payments_decode(data: DecodePayment):
|
||||
payment_str = data.data
|
||||
try:
|
||||
if data["data"][:5] == "LNURL":
|
||||
url = lnurl.decode(data["data"])
|
||||
if payment_str[:5] == "LNURL":
|
||||
url = lnurl.decode(payment_str)
|
||||
return {"domain": url}
|
||||
else:
|
||||
invoice = bolt11.decode(data["data"])
|
||||
invoice = bolt11.decode(payment_str)
|
||||
return {
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"amount_msat": invoice.amount_msat,
|
||||
|
|
@ -559,6 +581,6 @@ async def api_fiat_as_sats(data: ConversionData):
|
|||
return output
|
||||
else:
|
||||
output[data.from_.upper()] = data.amount
|
||||
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.to)
|
||||
output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
|
||||
output["BTC"] = output["sats"] / 100000000
|
||||
return output
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ from lnbits.core.models import User
|
|||
from lnbits.decorators import check_user_exists
|
||||
from lnbits.helpers import template_renderer, url_for
|
||||
from lnbits.settings import (
|
||||
LNBITS_ALLOWED_USERS,
|
||||
LNBITS_ADMIN_USERS,
|
||||
LNBITS_ALLOWED_USERS,
|
||||
LNBITS_SITE_TITLE,
|
||||
SERVICE_FEE,
|
||||
)
|
||||
|
|
@ -226,7 +226,9 @@ async def lnurl_balance_notify(request: Request, service: str):
|
|||
redeem_lnurl_withdraw(bc.wallet, bc.url)
|
||||
|
||||
|
||||
@core_html_routes.get("/lnurlwallet", response_class=RedirectResponse)
|
||||
@core_html_routes.get(
|
||||
"/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet"
|
||||
)
|
||||
async def lnurlwallet(request: Request):
|
||||
async with db.connect() as conn:
|
||||
account = await create_account(conn=conn)
|
||||
|
|
|
|||
|
|
@ -130,9 +130,15 @@ class Database(Compat):
|
|||
)
|
||||
)
|
||||
else:
|
||||
if os.path.isdir(LNBITS_DATA_FOLDER):
|
||||
self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
|
||||
database_uri = f"sqlite:///{self.path}"
|
||||
self.type = SQLITE
|
||||
else:
|
||||
raise NotADirectoryError(
|
||||
f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created"
|
||||
f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again"
|
||||
)
|
||||
|
||||
self.schema = self.name
|
||||
if self.name.startswith("ext_"):
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ from starlette.requests import Request
|
|||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.core.models import User, Wallet
|
||||
from lnbits.requestvars import g
|
||||
from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS
|
||||
from lnbits.settings import (
|
||||
LNBITS_ALLOWED_USERS,
|
||||
LNBITS_ADMIN_USERS,
|
||||
LNBITS_ADMIN_EXTENSIONS,
|
||||
)
|
||||
|
||||
|
||||
class KeyChecker(SecurityBase):
|
||||
|
|
@ -122,7 +126,7 @@ async def get_key_type(
|
|||
# 0: admin
|
||||
# 1: invoice
|
||||
# 2: invalid
|
||||
pathname = r['path'].split('/')[1]
|
||||
pathname = r["path"].split("/")[1]
|
||||
|
||||
if not api_key_header and not api_key_query:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
|
@ -133,8 +137,12 @@ async def get_key_type(
|
|||
checker = WalletAdminKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(0, checker.wallet)
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||
)
|
||||
return wallet
|
||||
except HTTPException as e:
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||
|
|
@ -147,9 +155,13 @@ async def get_key_type(
|
|||
try:
|
||||
checker = WalletInvoiceKeyChecker(api_key=token)
|
||||
await checker.__call__(r)
|
||||
wallet = WalletTypeInfo(0, checker.wallet)
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
|
||||
raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
|
||||
wallet = WalletTypeInfo(1, checker.wallet)
|
||||
if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (
|
||||
LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
|
||||
)
|
||||
return wallet
|
||||
except HTTPException as e:
|
||||
if e.status_code == HTTPStatus.BAD_REQUEST:
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ async def get_copilot(copilot_id: str) -> Copilots:
|
|||
|
||||
async def get_copilots(user: str) -> List[Copilots]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM copilot.newer_copilots WHERE user = ?", (user,)
|
||||
'SELECT * FROM copilot.newer_copilots WHERE "user" = ?', (user,)
|
||||
)
|
||||
return [Copilots(**row) for row in rows]
|
||||
|
||||
|
|
|
|||
11
lnbits/extensions/discordbot/Pipfile
Normal file
11
lnbits/extensions/discordbot/Pipfile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[[source]]
|
||||
url = "https://pypi.python.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
|
||||
[dev-packages]
|
||||
|
||||
[requires]
|
||||
python_version = "3.9"
|
||||
34
lnbits/extensions/discordbot/README.md
Normal file
34
lnbits/extensions/discordbot/README.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Discord Bot
|
||||
|
||||
## Provide LNbits wallets for all your Discord users
|
||||
|
||||
_This extension is a modifed version of LNbits [User Manager](../usermanager/README.md)_
|
||||
|
||||
The intended usage of this extension is to connect it to a specifically designed [Discord Bot](https://github.com/chrislennon/lnbits-discord-bot) leveraging LNbits as a community based lightning node.
|
||||
|
||||
## Setup
|
||||
This bot can target [lnbits.com](https://lnbits.com) or a self hosted instance.
|
||||
|
||||
To setup and run the bot instructions are located [here](https://github.com/chrislennon/lnbits-discord-bot#installation)
|
||||
|
||||
## Usage
|
||||
This bot will allow users to interact with it in the following ways [full command list](https://github.com/chrislennon/lnbits-discord-bot#commands):
|
||||
|
||||
`/create` Will create a wallet for the Discord user
|
||||
- (currently limiting 1 Discord user == 1 LNbits user == 1 user wallet)
|
||||
|
||||

|
||||
|
||||
`/balance` Will show the balance of the users wallet.
|
||||
|
||||

|
||||
|
||||
`/tip @user [amount]` Will sent money from one user to another
|
||||
- If the recieving user does not have a wallet, one will be created for them
|
||||
- The receiving user will receive a direct message from the bot with a link to their wallet
|
||||
|
||||

|
||||
|
||||
`/payme [amount] [description]` Will open an invoice that can be paid by any user
|
||||
|
||||

|
||||
25
lnbits/extensions/discordbot/__init__.py
Normal file
25
lnbits/extensions/discordbot/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from fastapi import APIRouter
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
db = Database("ext_discordbot")
|
||||
|
||||
discordbot_static_files = [
|
||||
{
|
||||
"path": "/discordbot/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/discordbot/static"),
|
||||
"name": "discordbot_static",
|
||||
}
|
||||
]
|
||||
|
||||
discordbot_ext: APIRouter = APIRouter(prefix="/discordbot", tags=["discordbot"])
|
||||
|
||||
|
||||
def discordbot_renderer():
|
||||
return template_renderer(["lnbits/extensions/discordbot/templates"])
|
||||
|
||||
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
6
lnbits/extensions/discordbot/config.json
Normal file
6
lnbits/extensions/discordbot/config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "Discord Bot",
|
||||
"short_description": "Generate users and wallets",
|
||||
"icon": "person_add",
|
||||
"contributors": ["bitcoingamer21"]
|
||||
}
|
||||
123
lnbits/extensions/discordbot/crud.py
Normal file
123
lnbits/extensions/discordbot/crud.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
from typing import List, Optional
|
||||
|
||||
from lnbits.core.crud import (
|
||||
create_account,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_payments,
|
||||
get_user,
|
||||
)
|
||||
from lnbits.core.models import Payment
|
||||
|
||||
from . import db
|
||||
from .models import CreateUserData, Users, Wallets
|
||||
|
||||
### Users
|
||||
|
||||
|
||||
async def create_discordbot_user(data: CreateUserData) -> Users:
|
||||
account = await create_account()
|
||||
user = await get_user(account.id)
|
||||
assert user, "Newly created user couldn't be retrieved"
|
||||
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=data.wallet_name)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO discordbot.users (id, name, admin, discord_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(user.id, data.user_name, data.admin_id, data.discord_id),
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet.id,
|
||||
data.admin_id,
|
||||
data.wallet_name,
|
||||
user.id,
|
||||
wallet.adminkey,
|
||||
wallet.inkey,
|
||||
),
|
||||
)
|
||||
|
||||
user_created = await get_discordbot_user(user.id)
|
||||
assert user_created, "Newly created user couldn't be retrieved"
|
||||
return user_created
|
||||
|
||||
|
||||
async def get_discordbot_user(user_id: str) -> Optional[Users]:
|
||||
row = await db.fetchone("SELECT * FROM discordbot.users WHERE id = ?", (user_id,))
|
||||
return Users(**row) if row else None
|
||||
|
||||
|
||||
async def get_discordbot_users(user_id: str) -> List[Users]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM discordbot.users WHERE admin = ?", (user_id,)
|
||||
)
|
||||
|
||||
return [Users(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_discordbot_user(user_id: str) -> None:
|
||||
wallets = await get_discordbot_wallets(user_id)
|
||||
for wallet in wallets:
|
||||
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
|
||||
|
||||
await db.execute("DELETE FROM discordbot.users WHERE id = ?", (user_id,))
|
||||
await db.execute("""DELETE FROM discordbot.wallets WHERE "user" = ?""", (user_id,))
|
||||
|
||||
|
||||
### Wallets
|
||||
|
||||
|
||||
async def create_discordbot_wallet(
|
||||
user_id: str, wallet_name: str, admin_id: str
|
||||
) -> Wallets:
|
||||
wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name)
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey),
|
||||
)
|
||||
wallet_created = await get_discordbot_wallet(wallet.id)
|
||||
assert wallet_created, "Newly created wallet couldn't be retrieved"
|
||||
return wallet_created
|
||||
|
||||
|
||||
async def get_discordbot_wallet(wallet_id: str) -> Optional[Wallets]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM discordbot.wallets WHERE id = ?", (wallet_id,)
|
||||
)
|
||||
return Wallets(**row) if row else None
|
||||
|
||||
|
||||
async def get_discordbot_wallets(admin_id: str) -> Optional[Wallets]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,)
|
||||
)
|
||||
return [Wallets(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_discordbot_users_wallets(user_id: str) -> Optional[Wallets]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,)
|
||||
)
|
||||
return [Wallets(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_discordbot_wallet_transactions(wallet_id: str) -> Optional[Payment]:
|
||||
return await get_payments(
|
||||
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
|
||||
)
|
||||
|
||||
|
||||
async def delete_discordbot_wallet(wallet_id: str, user_id: str) -> None:
|
||||
await delete_wallet(user_id=user_id, wallet_id=wallet_id)
|
||||
await db.execute("DELETE FROM discordbot.wallets WHERE id = ?", (wallet_id,))
|
||||
30
lnbits/extensions/discordbot/migrations.py
Normal file
30
lnbits/extensions/discordbot/migrations.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial users table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE discordbot.users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
admin TEXT NOT NULL,
|
||||
discord_id TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial wallets table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE discordbot.wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
admin TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
"user" TEXT NOT NULL,
|
||||
adminkey TEXT NOT NULL,
|
||||
inkey TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
38
lnbits/extensions/discordbot/models.py
Normal file
38
lnbits/extensions/discordbot/models.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from sqlite3 import Row
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CreateUserData(BaseModel):
|
||||
user_name: str = Query(...)
|
||||
wallet_name: str = Query(...)
|
||||
admin_id: str = Query(...)
|
||||
discord_id: str = Query("")
|
||||
|
||||
|
||||
class CreateUserWallet(BaseModel):
|
||||
user_id: str = Query(...)
|
||||
wallet_name: str = Query(...)
|
||||
admin_id: str = Query(...)
|
||||
|
||||
|
||||
class Users(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
admin: str
|
||||
discord_id: str
|
||||
|
||||
|
||||
class Wallets(BaseModel):
|
||||
id: str
|
||||
admin: str
|
||||
name: str
|
||||
user: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Wallets":
|
||||
return cls(**dict(row))
|
||||
BIN
lnbits/extensions/discordbot/static/stack.png
Normal file
BIN
lnbits/extensions/discordbot/static/stack.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
260
lnbits/extensions/discordbot/templates/discordbot/_api_docs.html
Normal file
260
lnbits/extensions/discordbot/templates/discordbot/_api_docs.html
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
Discord Bot: Connect Discord users to LNbits.
|
||||
</h5>
|
||||
<p>
|
||||
Connect your LNbits instance to a <a href="https://github.com/chrislennon/lnbits-discord-bot">Discord Bot</a> leveraging LNbits as a community based lightning node.<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/chrislennon">Chris Lennon</a></small
|
||||
> <br />
|
||||
<small>
|
||||
Based on User Manager, by <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET users">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/discordbot/api/v1/users</code
|
||||
>
|
||||
<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 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>JSON list of users</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}discordbot/api/v1/users -H
|
||||
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET user">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/discordbot/api/v1/users/<user_id></code
|
||||
>
|
||||
<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 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>JSON list of users</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}discordbot/api/v1/users/<user_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET wallets">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/discordbot/api/v1/wallets/<user_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <string>}</code>
|
||||
<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 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>JSON wallet data</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}discordbot/api/v1/wallets/<user_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="GET transactions">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/discordbot/api/v1/wallets<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <string>}</code>
|
||||
<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 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>JSON a wallets transactions</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}discordbot/api/v1/wallets<wallet_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="POST user + initial wallet"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span>
|
||||
/discordbot/api/v1/users</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": <string>, "Content-type":
|
||||
"application/json"}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json) - "admin_id" is a YOUR user ID
|
||||
</h5>
|
||||
<code
|
||||
>{"admin_id": <string>, "user_name": <string>,
|
||||
"wallet_name": <string>,"discord_id": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "name": <string>, "admin":
|
||||
<string>, "discord_id": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}discordbot/api/v1/users -d
|
||||
'{"admin_id": "{{ user.id }}", "wallet_name": <string>,
|
||||
"user_name": <string>, "discord_id": <string>}' -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}" -H "Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="POST wallet">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-green">POST</span>
|
||||
/discordbot/api/v1/wallets</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code
|
||||
>{"X-Api-Key": <string>, "Content-type":
|
||||
"application/json"}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Body (application/json) - "admin_id" is a YOUR user ID
|
||||
</h5>
|
||||
<code
|
||||
>{"user_id": <string>, "wallet_name": <string>,
|
||||
"admin_id": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "admin": <string>, "name":
|
||||
<string>, "user": <string>, "adminkey": <string>,
|
||||
"inkey": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}discordbot/api/v1/wallets -d
|
||||
'{"user_id": <string>, "wallet_name": <string>,
|
||||
"admin_id": "{{ user.id }}"}' -H "X-Api-Key: {{ user.wallets[0].inkey
|
||||
}}" -H "Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="DELETE user and their wallets"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-red">DELETE</span>
|
||||
/discordbot/api/v1/users/<user_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}discordbot/api/v1/users/<user_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="DELETE wallet">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-red">DELETE</span>
|
||||
/discordbot/api/v1/wallets/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}discordbot/api/v1/wallets/<wallet_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="POST activate extension"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/discordbot/api/v1/extensions</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}discordbot/api/v1/extensions -d
|
||||
'{"userid": <string>, "extension": <string>, "active":
|
||||
<integer>}' -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H
|
||||
"Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
464
lnbits/extensions/discordbot/templates/discordbot/index.html
Normal file
464
lnbits/extensions/discordbot/templates/discordbot/index.html
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
{% 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>
|
||||
<center><img src="/discordbot/static/stack.png" height="200" /></center>
|
||||
This extension is designed to be used through its API by a Discord Bot,
|
||||
currently you have to install the bot
|
||||
<a
|
||||
href="https://github.com/chrislennon/lnbits-discord-bot/#installation"
|
||||
>yourself</a
|
||||
><br />
|
||||
|
||||
Soon™ there will be a much easier one-click install discord bot...
|
||||
</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">Users</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportUsersCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="users"
|
||||
row-key="id"
|
||||
:columns="usersTable.columns"
|
||||
:pagination.sync="usersTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<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 v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteUser(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</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">Wallets</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportWalletsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="wallets"
|
||||
row-key="id"
|
||||
:columns="walletsTable.columns"
|
||||
:pagination.sync="walletsTable.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="props.row.walllink"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip> Link to wallet </q-tooltip>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteWallet(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits Discord Bot Extension
|
||||
<!--{{SITE_TITLE}} Discord Bot Extension-->
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "discordbot/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="userDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendUserFormData" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="userDialog.data.usrname"
|
||||
label="Username"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="userDialog.data.walname"
|
||||
label="Initial wallet name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="userDialog.data.discord_id"
|
||||
label="Discord ID"
|
||||
></q-input>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="userDialog.data.walname == null"
|
||||
type="submit"
|
||||
>Create User</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="walletDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendWalletFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="walletDialog.data.user"
|
||||
:options="userOptions"
|
||||
label="User *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="walletDialog.data.walname"
|
||||
label="Wallet name"
|
||||
></q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="walletDialog.data.walname == null"
|
||||
type="submit"
|
||||
>Create Wallet</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapUserManager = 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.walllink = ['../wallet?usr=', obj.user, '&wal=', obj.id].join('')
|
||||
obj._data = _.clone(obj)
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
wallets: [],
|
||||
users: [],
|
||||
|
||||
usersTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Username', field: 'name'},
|
||||
{name: 'discord_id', align: 'left', label: 'discord_id', field: 'discord_id'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
walletsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{name: 'user', align: 'left', label: 'User', field: 'user'},
|
||||
{
|
||||
name: 'adminkey',
|
||||
align: 'left',
|
||||
label: 'Admin Key',
|
||||
field: 'adminkey'
|
||||
},
|
||||
{name: 'inkey', align: 'left', label: 'Invoice Key', field: 'inkey'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
walletDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
userDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userOptions: function () {
|
||||
return this.users.map(function (obj) {
|
||||
console.log(obj.id)
|
||||
return {
|
||||
value: String(obj.id),
|
||||
label: String(obj.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
///////////////Users////////////////////////////
|
||||
|
||||
getUsers: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/discordbot/api/v1/users',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.users = response.data.map(function (obj) {
|
||||
return mapUserManager(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
openUserUpdateDialog: function (linkId) {
|
||||
var link = _.findWhere(this.users, {id: linkId})
|
||||
|
||||
this.userDialog.data = _.clone(link._data)
|
||||
this.userDialog.show = true
|
||||
},
|
||||
sendUserFormData: function () {
|
||||
if (this.userDialog.data.id) {
|
||||
} else {
|
||||
var data = {
|
||||
admin_id: this.g.user.id,
|
||||
user_name: this.userDialog.data.usrname,
|
||||
wallet_name: this.userDialog.data.walname,
|
||||
discord_id: this.userDialog.data.discord_id
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
this.createUser(data)
|
||||
}
|
||||
},
|
||||
|
||||
createUser: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/discordbot/api/v1/users',
|
||||
this.g.user.wallets[0].inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.users.push(mapUserManager(response.data))
|
||||
self.userDialog.show = false
|
||||
self.userDialog.data = {}
|
||||
data = {}
|
||||
self.getWallets()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteUser: function (userId) {
|
||||
var self = this
|
||||
|
||||
console.log(userId)
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this User link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/discordbot/api/v1/users/' + userId,
|
||||
self.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.users = _.reject(self.users, function (obj) {
|
||||
return obj.id == userId
|
||||
})
|
||||
self.getWallets()
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
exportUsersCSV: function () {
|
||||
LNbits.utils.exportCSV(this.usersTable.columns, this.users)
|
||||
},
|
||||
|
||||
///////////////Wallets////////////////////////////
|
||||
|
||||
getWallets: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/discordbot/api/v1/wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.wallets = response.data.map(function (obj) {
|
||||
return mapUserManager(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
openWalletUpdateDialog: function (linkId) {
|
||||
var link = _.findWhere(this.users, {id: linkId})
|
||||
|
||||
this.walletDialog.data = _.clone(link._data)
|
||||
this.walletDialog.show = true
|
||||
},
|
||||
sendWalletFormData: function () {
|
||||
if (this.walletDialog.data.id) {
|
||||
} else {
|
||||
var data = {
|
||||
user_id: this.walletDialog.data.user,
|
||||
admin_id: this.g.user.id,
|
||||
wallet_name: this.walletDialog.data.walname
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
this.createWallet(data)
|
||||
}
|
||||
},
|
||||
|
||||
createWallet: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/discordbot/api/v1/wallets',
|
||||
this.g.user.wallets[0].inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.wallets.push(mapUserManager(response.data))
|
||||
self.walletDialog.show = false
|
||||
self.walletDialog.data = {}
|
||||
data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteWallet: function (userId) {
|
||||
var self = this
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this wallet link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/discordbot/api/v1/wallets/' + userId,
|
||||
self.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.wallets = _.reject(self.wallets, function (obj) {
|
||||
return obj.id == userId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportWalletsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.walletsTable.columns, this.wallets)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getUsers()
|
||||
this.getWallets()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
15
lnbits/extensions/discordbot/views.py
Normal file
15
lnbits/extensions/discordbot/views.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import discordbot_ext, discordbot_renderer
|
||||
|
||||
|
||||
@discordbot_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return discordbot_renderer().TemplateResponse(
|
||||
"discordbot/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
125
lnbits/extensions/discordbot/views_api.py
Normal file
125
lnbits/extensions/discordbot/views_api.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core import update_user_extension
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
|
||||
from . import discordbot_ext
|
||||
from .crud import (
|
||||
create_discordbot_user,
|
||||
create_discordbot_wallet,
|
||||
delete_discordbot_user,
|
||||
delete_discordbot_wallet,
|
||||
get_discordbot_user,
|
||||
get_discordbot_users,
|
||||
get_discordbot_users_wallets,
|
||||
get_discordbot_wallet,
|
||||
get_discordbot_wallet_transactions,
|
||||
get_discordbot_wallets,
|
||||
)
|
||||
from .models import CreateUserData, CreateUserWallet
|
||||
|
||||
# Users
|
||||
|
||||
|
||||
@discordbot_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
|
||||
async def api_discordbot_users(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
user_id = wallet.wallet.user
|
||||
return [user.dict() for user in await get_discordbot_users(user_id)]
|
||||
|
||||
|
||||
@discordbot_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK)
|
||||
async def api_discordbot_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
user = await get_discordbot_user(user_id)
|
||||
return user.dict()
|
||||
|
||||
|
||||
@discordbot_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED)
|
||||
async def api_discordbot_users_create(
|
||||
data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
user = await create_discordbot_user(data)
|
||||
full = user.dict()
|
||||
full["wallets"] = [
|
||||
wallet.dict() for wallet in await get_discordbot_users_wallets(user.id)
|
||||
]
|
||||
return full
|
||||
|
||||
|
||||
@discordbot_ext.delete("/api/v1/users/{user_id}")
|
||||
async def api_discordbot_users_delete(
|
||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
user = await get_discordbot_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
)
|
||||
await delete_discordbot_user(user_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
# Activate Extension
|
||||
|
||||
|
||||
@discordbot_ext.post("/api/v1/extensions")
|
||||
async def api_discordbot_activate_extension(
|
||||
extension: str = Query(...), userid: str = Query(...), active: bool = Query(...)
|
||||
):
|
||||
user = await get_user(userid)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
)
|
||||
update_user_extension(user_id=userid, extension=extension, active=active)
|
||||
return {"extension": "updated"}
|
||||
|
||||
|
||||
# Wallets
|
||||
|
||||
|
||||
@discordbot_ext.post("/api/v1/wallets")
|
||||
async def api_discordbot_wallets_create(
|
||||
data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
user = await create_discordbot_wallet(
|
||||
user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id
|
||||
)
|
||||
return user.dict()
|
||||
|
||||
|
||||
@discordbot_ext.get("/api/v1/wallets")
|
||||
async def api_discordbot_wallets(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
admin_id = wallet.wallet.user
|
||||
return [wallet.dict() for wallet in await get_discordbot_wallets(admin_id)]
|
||||
|
||||
|
||||
@discordbot_ext.get("/api/v1/transactions/{wallet_id}")
|
||||
async def api_discordbot_wallet_transactions(
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
return await get_discordbot_wallet_transactions(wallet_id)
|
||||
|
||||
|
||||
@discordbot_ext.get("/api/v1/wallets/{user_id}")
|
||||
async def api_discordbot_users_wallets(
|
||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
return [s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id)]
|
||||
|
||||
|
||||
@discordbot_ext.delete("/api/v1/wallets/{wallet_id}")
|
||||
async def api_discordbot_wallets_delete(
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
get_wallet = await get_discordbot_wallet(wallet_id)
|
||||
if not get_wallet:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
await delete_discordbot_wallet(wallet_id, get_wallet.user)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
|
@ -76,7 +76,7 @@ async def delete_ticket(payment_hash: str) -> None:
|
|||
|
||||
|
||||
async def delete_event_tickets(event_id: str) -> None:
|
||||
await db.execute("DELETE FROM events.tickets WHERE event = ?", (event_id,))
|
||||
await db.execute("DELETE FROM events.ticket WHERE event = ?", (event_id,))
|
||||
|
||||
|
||||
# EVENTS
|
||||
|
|
|
|||
11
lnbits/extensions/example/README.md
Normal file
11
lnbits/extensions/example/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<h1>Example Extension</h1>
|
||||
<h2>*tagline*</h2>
|
||||
This is an example extension to help you organise and build you own.
|
||||
|
||||
Try to include an image
|
||||
<img src="https://i.imgur.com/9i4xcQB.png">
|
||||
|
||||
|
||||
<h2>If your extension has API endpoints, include useful ones here</h2>
|
||||
|
||||
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
|
||||
16
lnbits/extensions/example/__init__.py
Normal file
16
lnbits/extensions/example/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
|
||||
db = Database("ext_example")
|
||||
|
||||
example_ext: APIRouter = APIRouter(prefix="/example", tags=["example"])
|
||||
|
||||
|
||||
def example_renderer():
|
||||
return template_renderer(["lnbits/extensions/example/templates"])
|
||||
|
||||
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
6
lnbits/extensions/example/example.config.json
Normal file
6
lnbits/extensions/example/example.config.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "Build your own!!",
|
||||
"short_description": "Join us, make an extension",
|
||||
"icon": "info",
|
||||
"contributors": ["github_username"]
|
||||
}
|
||||
10
lnbits/extensions/example/migrations.py
Normal file
10
lnbits/extensions/example/migrations.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# async def m001_initial(db):
|
||||
# await db.execute(
|
||||
# f"""
|
||||
# CREATE TABLE example.example (
|
||||
# id TEXT PRIMARY KEY,
|
||||
# wallet TEXT NOT NULL,
|
||||
# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
# );
|
||||
# """
|
||||
# )
|
||||
5
lnbits/extensions/example/models.py
Normal file
5
lnbits/extensions/example/models.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# from pydantic import BaseModel
|
||||
|
||||
# class Example(BaseModel):
|
||||
# id: str
|
||||
# wallet: str
|
||||
59
lnbits/extensions/example/templates/example/index.html
Normal file
59
lnbits/extensions/example/templates/example/index.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-mt-none q-mb-md">
|
||||
Frameworks used by {{SITE_TITLE}}
|
||||
</h5>
|
||||
<q-list>
|
||||
<q-item
|
||||
v-for="tool in tools"
|
||||
:key="tool.name"
|
||||
tag="a"
|
||||
:href="tool.url"
|
||||
target="_blank"
|
||||
>
|
||||
{% raw %}
|
||||
<!-- with raw Flask won't try to interpret the Vue moustaches -->
|
||||
<q-item-section>
|
||||
<q-item-label>{{ tool.name }}</q-item-label>
|
||||
<q-item-label caption>{{ tool.language }}</q-item-label>
|
||||
</q-item-section>
|
||||
{% endraw %}
|
||||
</q-item>
|
||||
</q-list>
|
||||
<q-separator class="q-my-lg"></q-separator>
|
||||
<p>
|
||||
A magical "g" is always available, with info about the user, wallets and
|
||||
extensions:
|
||||
</p>
|
||||
<code class="text-caption">{% raw %}{{ g }}{% endraw %}</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
tools: []
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
var self = this
|
||||
|
||||
// axios is available for making requests
|
||||
axios({
|
||||
method: 'GET',
|
||||
url: '/example/api/v1/tools',
|
||||
headers: {
|
||||
'X-example-header': 'not-used'
|
||||
}
|
||||
}).then(function (response) {
|
||||
self.tools = response.data
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
18
lnbits/extensions/example/views.py
Normal file
18
lnbits/extensions/example/views.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from fastapi import FastAPI, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from lnbits.core.models import User
|
||||
from lnbits.decorators import check_user_exists
|
||||
|
||||
from . import example_ext, example_renderer
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@example_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return example_renderer().TemplateResponse(
|
||||
"example/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
35
lnbits/extensions/example/views_api.py
Normal file
35
lnbits/extensions/example/views_api.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# views_api.py is for you API endpoints that could be hit by another service
|
||||
|
||||
# add your dependencies here
|
||||
|
||||
# import httpx
|
||||
# (use httpx just like requests, except instead of response.ok there's only the
|
||||
# response.is_error that is its inverse)
|
||||
|
||||
from . import example_ext
|
||||
|
||||
# add your endpoints here
|
||||
|
||||
|
||||
@example_ext.get("/api/v1/tools")
|
||||
async def api_example():
|
||||
"""Try to add descriptions for others."""
|
||||
tools = [
|
||||
{
|
||||
"name": "fastAPI",
|
||||
"url": "https://fastapi.tiangolo.com/",
|
||||
"language": "Python",
|
||||
},
|
||||
{
|
||||
"name": "Vue.js",
|
||||
"url": "https://vuejs.org/",
|
||||
"language": "JavaScript",
|
||||
},
|
||||
{
|
||||
"name": "Quasar Framework",
|
||||
"url": "https://quasar.dev/",
|
||||
"language": "JavaScript",
|
||||
},
|
||||
]
|
||||
|
||||
return tools
|
||||
|
|
@ -12,7 +12,7 @@ async def create_jukebox(
|
|||
juke_id = urlsafe_short_hash()
|
||||
result = 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
|
|
@ -41,6 +41,7 @@ async def update_jukebox(
|
|||
q = ", ".join([f"{field[0]} = ?" for field in data])
|
||||
items = [f"{field[1]}" for field in data]
|
||||
items.append(juke_id)
|
||||
q = q.replace("user", '"user"', 1) # hack to make user be "user"!
|
||||
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items))
|
||||
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
|
||||
return Jukebox(**row) if row else None
|
||||
|
|
@ -57,11 +58,11 @@ async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
|
|||
|
||||
|
||||
async def get_jukeboxs(user: str) -> List[Jukebox]:
|
||||
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
|
||||
rows = await db.fetchall('SELECT * FROM jukebox.jukebox WHERE "user" = ?', (user,))
|
||||
for row in rows:
|
||||
if row.sp_playlists == None:
|
||||
await delete_jukebox(row.id)
|
||||
rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,))
|
||||
rows = await db.fetchall('SELECT * FROM jukebox.jukebox WHERE "user" = ?', (user,))
|
||||
|
||||
return [Jukebox(**row) for row in rows]
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,6 @@ async def api_check_credentials_check(
|
|||
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
|
||||
return jukebox
|
||||
|
||||
|
||||
|
|
@ -442,7 +441,7 @@ async def api_get_jukebox_currently(
|
|||
token = await api_get_token(juke_id)
|
||||
if token == False:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="INvoice not paid"
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Invoice not paid"
|
||||
)
|
||||
elif retry:
|
||||
raise HTTPException(
|
||||
|
|
@ -456,5 +455,6 @@ async def api_get_jukebox_currently(
|
|||
)
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong"
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Something went wrong, or no song is playing yet",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
# from mmap import MAP_DENYWRITE
|
||||
|
||||
from fastapi.param_functions import Depends
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@
|
|||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
|
|
@ -136,9 +137,19 @@
|
|||
:href="'mailto:' + props.row.email"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="launch"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="ticketCard(props)"
|
||||
><q-tooltip> Click to show ticket </q-tooltip></q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
{{ col.label == "Ticket" ? col.value.length > 20 ? `${col.value.substring(0, 20)}...` : col.value : col.value }}
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width>
|
||||
|
|
@ -249,6 +260,29 @@
|
|||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- Read Ticket Dialog -->
|
||||
<q-dialog v-model="ticketDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
{% raw %}
|
||||
<q-card-section>
|
||||
<h4 class="text-subtitle1 q-my-none">
|
||||
<i>{{this.ticketDialog.data.name}}</i> sent a ticket
|
||||
</h4>
|
||||
<div v-if="this.ticketDialog.data.email">
|
||||
<small>{{this.ticketDialog.data.email}}</small>
|
||||
</div>
|
||||
<small>{{this.ticketDialog.data.date}}</small>
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-section>
|
||||
<p>{{this.ticketDialog.data.content}}</p>
|
||||
</q-card-section>
|
||||
{% endraw %}
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="CLOSE" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
|
|
@ -318,6 +352,10 @@
|
|||
formDialog: {
|
||||
show: false,
|
||||
data: {flatrate: false}
|
||||
},
|
||||
ticketDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -372,6 +410,16 @@
|
|||
})
|
||||
})
|
||||
},
|
||||
ticketCard(ticket){
|
||||
this.ticketDialog.show = true
|
||||
let {date, email, ltext, name} = ticket.row
|
||||
this.ticketDialog.data = {
|
||||
date,
|
||||
email,
|
||||
content: ltext,
|
||||
name
|
||||
}
|
||||
},
|
||||
exportticketsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
||||
},
|
||||
|
|
@ -421,12 +469,13 @@
|
|||
},
|
||||
updateformDialog: function (formId) {
|
||||
var link = _.findWhere(this.forms, {id: formId})
|
||||
console.log("LINK", link)
|
||||
|
||||
this.formDialog.data.id = link.id
|
||||
this.formDialog.data.wallet = link.wallet
|
||||
this.formDialog.data.name = link.name
|
||||
this.formDialog.data.description = link.description
|
||||
this.formDialog.data.flatrate = link.flatrate
|
||||
this.formDialog.data.flatrate = Boolean(link.flatrate)
|
||||
this.formDialog.data.amount = link.amount
|
||||
this.formDialog.show = true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ async def api_ticket_make_ticket(data: CreateTicketData, form_id):
|
|||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail=f"LNTicket does not exist."
|
||||
)
|
||||
if data.sats < 1:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail=f"0 invoices not allowed."
|
||||
)
|
||||
|
||||
nwords = len(re.split(r"\s+", data.ltext))
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
|||
|
||||
returning = "" if db.type == SQLITE else "RETURNING ID"
|
||||
method = db.execute if db.type == SQLITE else db.fetchone
|
||||
# database only allows int4 entries for min and max. For fiat currencies,
|
||||
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
|
||||
if data.currency and data.fiat_base_multiplier:
|
||||
data.min *= data.fiat_base_multiplier
|
||||
data.max *= data.fiat_base_multiplier
|
||||
|
||||
result = await (method)(
|
||||
f"""
|
||||
INSERT INTO lnurlp.pay_links (
|
||||
|
|
@ -22,9 +28,10 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
|||
success_text,
|
||||
success_url,
|
||||
comment_chars,
|
||||
currency
|
||||
currency,
|
||||
fiat_base_multiplier
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?)
|
||||
{returning}
|
||||
""",
|
||||
(
|
||||
|
|
@ -37,6 +44,7 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
|||
data.success_url,
|
||||
data.comment_chars,
|
||||
data.currency,
|
||||
data.fiat_base_multiplier,
|
||||
),
|
||||
)
|
||||
if db.type == SQLITE:
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ async def api_lnurl_response(request: Request, link_id):
|
|||
|
||||
resp = LnurlPayResponse(
|
||||
callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
|
||||
min_sendable=math.ceil(link.min * rate) * 1000,
|
||||
min_sendable=round(link.min * rate) * 1000,
|
||||
max_sendable=round(link.max * rate) * 1000,
|
||||
metadata=link.lnurlpay_metadata,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -50,3 +50,13 @@ async def m003_min_max_comment_fiat(db):
|
|||
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;")
|
||||
await db.execute("UPDATE lnurlp.pay_links SET max = min;")
|
||||
await db.execute("DROP TABLE lnurlp.invoices")
|
||||
|
||||
|
||||
async def m004_fiat_base_multiplier(db):
|
||||
"""
|
||||
Store the multiplier for fiat prices. We store the price in cents and
|
||||
remember to multiply by 100 when we use it to convert to Dollars.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,20 +11,21 @@ from pydantic import BaseModel
|
|||
|
||||
class CreatePayLinkData(BaseModel):
|
||||
description: str
|
||||
min: int = Query(0.01, ge=0.01)
|
||||
max: int = Query(0.01, ge=0.01)
|
||||
min: float = Query(1, ge=0.01)
|
||||
max: float = Query(1, ge=0.01)
|
||||
currency: str = Query(None)
|
||||
comment_chars: int = Query(0, ge=0, lt=800)
|
||||
webhook_url: str = Query(None)
|
||||
success_text: str = Query(None)
|
||||
success_url: str = Query(None)
|
||||
fiat_base_multiplier: int = Query(100, ge=1)
|
||||
|
||||
|
||||
class PayLink(BaseModel):
|
||||
id: int
|
||||
wallet: str
|
||||
description: str
|
||||
min: int
|
||||
min: float
|
||||
served_meta: int
|
||||
served_pr: int
|
||||
webhook_url: Optional[str]
|
||||
|
|
@ -32,11 +33,15 @@ class PayLink(BaseModel):
|
|||
success_url: Optional[str]
|
||||
currency: Optional[str]
|
||||
comment_chars: int
|
||||
max: int
|
||||
max: float
|
||||
fiat_base_multiplier: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "PayLink":
|
||||
data = dict(row)
|
||||
if data["currency"] and data["fiat_base_multiplier"]:
|
||||
data["min"] /= data["fiat_base_multiplier"]
|
||||
data["max"] /= data["fiat_base_multiplier"]
|
||||
return cls(**data)
|
||||
|
||||
def lnurl(self, req: Request) -> str:
|
||||
|
|
|
|||
|
|
@ -76,13 +76,14 @@ async def api_link_create_or_update(
|
|||
link_id=None,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
):
|
||||
|
||||
if data.min > data.max:
|
||||
raise HTTPException(
|
||||
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if data.currency == None and (
|
||||
round(data.min) != data.min or round(data.max) != data.max
|
||||
round(data.min) != data.min or round(data.max) != data.max or data.min < 1
|
||||
):
|
||||
raise HTTPException(
|
||||
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ def hotp(key, counter, digits=6, digest="sha1"):
|
|||
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
|
||||
counter = struct.pack(">Q", counter)
|
||||
mac = hmac.new(key, counter, digest).digest()
|
||||
offset = mac[-1] & 0x0f
|
||||
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff
|
||||
offset = mac[-1] & 0x0F
|
||||
binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
return str(binary)[-digits:].zfill(digits)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -54,8 +54,7 @@ async def api_paywall_delete(
|
|||
|
||||
@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")
|
||||
async def api_paywall_create_invoice(
|
||||
data: CreatePaywallInvoice,
|
||||
paywall_id: str = Query(None)
|
||||
data: CreatePaywallInvoice, paywall_id: str = Query(None)
|
||||
):
|
||||
paywall = await get_paywall(paywall_id)
|
||||
if data.amount < paywall.amount:
|
||||
|
|
@ -78,7 +77,9 @@ async def api_paywall_create_invoice(
|
|||
|
||||
|
||||
@paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}")
|
||||
async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id: str = Query(None)):
|
||||
async def api_paywal_check_invoice(
|
||||
data: CheckPaywallInvoice, paywall_id: str = Query(None)
|
||||
):
|
||||
paywall = await get_paywall(paywall_id)
|
||||
payment_hash = data.payment_hash
|
||||
if not paywall:
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
label="lightning⚡"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin onchain payment method not available
|
||||
bitcoin lightning payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
|
|
@ -86,7 +86,7 @@
|
|||
label="onchain⛓️"
|
||||
>
|
||||
<q-tooltip>
|
||||
bitcoin lightning payment method not available
|
||||
bitcoin onchain payment method not available
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class Service(BaseModel):
|
|||
onchain: Optional[str]
|
||||
servicename: str # Currently, this will just always be "Streamlabs"
|
||||
authenticated: bool # Whether a token (see below) has been acquired yet
|
||||
token: Optional[int] # The token with which to authenticate requests
|
||||
token: Optional[str] # The token with which to authenticate requests
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Service":
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
donationDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
name: '',
|
||||
name: null,
|
||||
sats: '',
|
||||
message: ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from starlette.responses import RedirectResponse
|
|||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
from lnbits.extensions.satspay.models import CreateCharge
|
||||
from lnbits.extensions.streamalerts.models import (
|
||||
CreateDonation,
|
||||
CreateService,
|
||||
|
|
@ -113,10 +114,10 @@ async def api_create_donation(data: CreateDonation, request: Request):
|
|||
service_id = data.service
|
||||
service = await get_service(service_id)
|
||||
charge_details = await get_charge_details(service.id)
|
||||
name = data.name
|
||||
name = data.name if data.name else "Anonymous"
|
||||
|
||||
description = f"{sats} sats donation from {name} to {service.twitchuser}"
|
||||
charge = await create_charge(
|
||||
create_charge_data = CreateCharge(
|
||||
amount=sats,
|
||||
completelink=f"https://twitch.tv/{service.twitchuser}",
|
||||
completelinktext="Back to Stream!",
|
||||
|
|
@ -124,6 +125,7 @@ async def api_create_donation(data: CreateDonation, request: Request):
|
|||
description=description,
|
||||
**charge_details,
|
||||
)
|
||||
charge = await create_charge(user=charge_details["user"], data=create_charge_data)
|
||||
await create_donation(
|
||||
id=charge.id,
|
||||
wallet=service.wallet,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from sqlite3 import Row
|
||||
from typing import NamedTuple, Optional
|
||||
from typing import Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -26,7 +26,7 @@ class createTip(BaseModel):
|
|||
message: str = ""
|
||||
|
||||
|
||||
class Tip(NamedTuple):
|
||||
class Tip(BaseModel):
|
||||
"""A Tip represents a single donation"""
|
||||
|
||||
id: str # This ID always corresponds to a satspay charge ID
|
||||
|
|
@ -55,7 +55,7 @@ class createTips(BaseModel):
|
|||
message: str
|
||||
|
||||
|
||||
class TipJar(NamedTuple):
|
||||
class TipJar(BaseModel):
|
||||
"""A TipJar represents a user's tip jar"""
|
||||
|
||||
id: int
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@
|
|||
'INR',
|
||||
'IQD',
|
||||
'IRR',
|
||||
'IRT',
|
||||
'ISK',
|
||||
'JEP',
|
||||
'JMD',
|
||||
|
|
|
|||
|
|
@ -16,39 +16,95 @@
|
|||
<div class="row justify-center full-width">
|
||||
<div class="col-12 col-sm-8 col-md-6 col-lg-4">
|
||||
<div class="keypad q-pa-sm">
|
||||
<q-btn unelevated @click="stack.push(1)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(1)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>1</q-btn
|
||||
>
|
||||
<q-btn unelevated @click="stack.push(2)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(2)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>2</q-btn
|
||||
>
|
||||
<q-btn unelevated @click="stack.push(3)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(3)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>3</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack = []"
|
||||
size="xl"
|
||||
color="pink"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
class="btn-cancel"
|
||||
>C</q-btn
|
||||
>
|
||||
<q-btn unelevated @click="stack.push(4)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(4)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>4</q-btn
|
||||
>
|
||||
<q-btn unelevated @click="stack.push(5)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(5)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>5</q-btn
|
||||
>
|
||||
<q-btn unelevated @click="stack.push(6)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(6)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>6</q-btn
|
||||
>
|
||||
<q-btn unelevated @click="stack.push(7)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(7)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>7</q-btn
|
||||
>
|
||||
<q-btn unelevated @click="stack.push(8)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(8)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>8</q-btn
|
||||
>
|
||||
<q-btn unelevated @click="stack.push(9)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(9)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>9</q-btn
|
||||
>
|
||||
<q-btn
|
||||
|
|
@ -56,7 +112,9 @@
|
|||
:disabled="amount == 0"
|
||||
@click="showInvoice()"
|
||||
size="xl"
|
||||
color="green"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
class="btn-confirm"
|
||||
>OK</q-btn
|
||||
>
|
||||
|
|
@ -64,17 +122,27 @@
|
|||
unelevated
|
||||
@click="stack.splice(-1, 1)"
|
||||
size="xl"
|
||||
color="grey-7"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>DEL</q-btn
|
||||
>
|
||||
<q-btn unelevated @click="stack.push(0)" size="xl" color="grey-8"
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="stack.push(0)"
|
||||
size="xl"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>0</q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
@click="urlDialog.show = true"
|
||||
size="xl"
|
||||
color="grey-7"
|
||||
:outline="!($q.dark.isActive)"
|
||||
rounded
|
||||
color="primary"
|
||||
>#</q-btn
|
||||
>
|
||||
</div>
|
||||
|
|
@ -140,8 +208,8 @@
|
|||
transition-show="fade"
|
||||
class="text-light-green"
|
||||
style="font-size: 40em"
|
||||
></q-icon
|
||||
></q-dialog>
|
||||
></q-icon>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
{% endblock %} {% block styles %}
|
||||
|
|
@ -152,9 +220,11 @@
|
|||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.keypad .btn {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-confirm {
|
||||
grid-row: auto/span 2;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
extension allows the creation and management of users and wallets.
|
||||
<br />For example, a games developer may be developing a game that needs
|
||||
each user to have their own wallet, LNbits can be included in the
|
||||
develpoers stack as the user and wallet manager.<br />
|
||||
developers stack as the user and wallet manager.<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
|
|
@ -97,7 +97,7 @@
|
|||
<q-card-section>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/usermanager/api/v1/wallets<wallet_id></code
|
||||
/usermanager/api/v1/transactions/<wallet_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <string>}</code>
|
||||
|
|
@ -109,7 +109,7 @@
|
|||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url
|
||||
}}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{
|
||||
}}usermanager/api/v1/transactions/<wallet_id> -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@
|
|||
.request(
|
||||
'GET',
|
||||
'/usermanager/api/v1/users',
|
||||
this.g.user.wallets[0].inkey
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.users = response.data.map(function (obj) {
|
||||
|
|
@ -362,7 +362,7 @@
|
|||
.request(
|
||||
'DELETE',
|
||||
'/usermanager/api/v1/users/' + userId,
|
||||
self.g.user.wallets[0].inkey
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.users = _.reject(self.users, function (obj) {
|
||||
|
|
@ -389,7 +389,7 @@
|
|||
.request(
|
||||
'GET',
|
||||
'/usermanager/api/v1/wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.wallets = response.data.map(function (obj) {
|
||||
|
|
@ -447,7 +447,7 @@
|
|||
.request(
|
||||
'DELETE',
|
||||
'/usermanager/api/v1/wallets/' + userId,
|
||||
self.g.user.wallets[0].inkey
|
||||
self.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.wallets = _.reject(self.wallets, function (obj) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from starlette.exceptions import HTTPException
|
|||
|
||||
from lnbits.core import update_user_extension
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import usermanager_ext
|
||||
from .crud import (
|
||||
|
|
@ -27,7 +27,7 @@ from .models import CreateUserData, CreateUserWallet
|
|||
|
||||
|
||||
@usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
|
||||
async def api_usermanager_users(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
async def api_usermanager_users(wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
user_id = wallet.wallet.user
|
||||
return [user.dict() for user in await get_usermanager_users(user_id)]
|
||||
|
||||
|
|
@ -52,7 +52,7 @@ async def api_usermanager_users_create(
|
|||
|
||||
@usermanager_ext.delete("/api/v1/users/{user_id}")
|
||||
async def api_usermanager_users_delete(
|
||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
user = await get_usermanager_user(user_id)
|
||||
if not user:
|
||||
|
|
@ -93,7 +93,7 @@ async def api_usermanager_wallets_create(
|
|||
|
||||
|
||||
@usermanager_ext.get("/api/v1/wallets")
|
||||
async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(require_admin_key)):
|
||||
admin_id = wallet.wallet.user
|
||||
return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)]
|
||||
|
||||
|
|
@ -107,7 +107,7 @@ async def api_usermanager_wallet_transactions(
|
|||
|
||||
@usermanager_ext.get("/api/v1/wallets/{user_id}")
|
||||
async def api_usermanager_users_wallets(
|
||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
user_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
return [
|
||||
s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id)
|
||||
|
|
@ -116,7 +116,7 @@ async def api_usermanager_users_wallets(
|
|||
|
||||
@usermanager_ext.delete("/api/v1/wallets/{wallet_id}")
|
||||
async def api_usermanager_wallets_delete(
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
get_wallet = await get_usermanager_wallet(wallet_id)
|
||||
if not get_wallet:
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)
|
|||
async def api_update_mempool(
|
||||
endpoint: str = Query(...), w: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
mempool = await update_mempool(endpoint, user=w.wallet.user)
|
||||
mempool = await update_mempool(**{"endpoint": endpoint}, user=w.wallet.user)
|
||||
return mempool.dict()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,10 @@ async def create_withdraw_link(
|
|||
unique_hash,
|
||||
k1,
|
||||
open_time,
|
||||
usescsv
|
||||
usescsv,
|
||||
webhook_url
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
link_id,
|
||||
|
|
@ -42,6 +43,7 @@ async def create_withdraw_link(
|
|||
urlsafe_short_hash(),
|
||||
int(datetime.now().timestamp()) + data.wait_time,
|
||||
usescsv,
|
||||
data.webhook_url
|
||||
),
|
||||
)
|
||||
link = await get_withdraw_link(link_id, 0)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import json
|
||||
import traceback
|
||||
import httpx
|
||||
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
|
|
@ -30,7 +33,9 @@ async def api_lnurl_response(request: Request, unique_hash):
|
|||
)
|
||||
|
||||
if link.is_spent:
|
||||
raise HTTPException(detail="Withdraw is spent.")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
||||
)
|
||||
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
||||
withdrawResponse = {
|
||||
"tag": "withdrawRequest",
|
||||
|
|
@ -48,7 +53,11 @@ async def api_lnurl_response(request: Request, unique_hash):
|
|||
|
||||
@withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback")
|
||||
async def api_lnurl_callback(
|
||||
unique_hash, request: Request, k1: str = Query(...), pr: str = Query(...)
|
||||
unique_hash,
|
||||
request: Request,
|
||||
k1: str = Query(...),
|
||||
pr: str = Query(...),
|
||||
id_unique_hash=None,
|
||||
):
|
||||
link = await get_withdraw_link_by_hash(unique_hash)
|
||||
now = int(datetime.now().timestamp())
|
||||
|
|
@ -58,20 +67,37 @@ async def api_lnurl_callback(
|
|||
)
|
||||
|
||||
if link.is_spent:
|
||||
raise HTTPException(status_code=HTTPStatus.OK, detail="Withdraw is spent.")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
||||
)
|
||||
|
||||
if link.k1 != k1:
|
||||
raise HTTPException(status_code=HTTPStatus.OK, detail="Bad request.")
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Bad request.")
|
||||
|
||||
if now < link.open_time:
|
||||
return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."}
|
||||
|
||||
try:
|
||||
usescsv = ""
|
||||
try:
|
||||
for x in range(1, link.uses - link.used):
|
||||
usecv = link.usescsv.split(",")
|
||||
usescsv += "," + str(usecv[x])
|
||||
usecsvback = usescsv
|
||||
|
||||
found = False
|
||||
if id_unique_hash is not None:
|
||||
useslist = link.usescsv.split(",")
|
||||
for ind, x in enumerate(useslist):
|
||||
tohash = link.id + link.unique_hash + str(x)
|
||||
if id_unique_hash == shortuuid.uuid(name=tohash):
|
||||
found = True
|
||||
useslist.pop(ind)
|
||||
usescsv = ",".join(useslist)
|
||||
if not found:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
||||
)
|
||||
else:
|
||||
usescsv = usescsv[1:]
|
||||
|
||||
changesback = {
|
||||
|
|
@ -89,16 +115,34 @@ async def api_lnurl_callback(
|
|||
|
||||
payment_request = pr
|
||||
|
||||
await pay_invoice(
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=link.wallet,
|
||||
payment_request=payment_request,
|
||||
max_sat=link.max_withdrawable,
|
||||
extra={"tag": "withdraw"},
|
||||
)
|
||||
|
||||
if link.webhook_url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
link.webhook_url,
|
||||
json={
|
||||
"payment_hash": payment_hash,
|
||||
"payment_request": payment_request,
|
||||
"lnurlw": link.id,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
except Exception as exc:
|
||||
# webhook fails shouldn't cause the lnurlw to fail since invoice is already paid
|
||||
print("Caught exception when dispatching webhook url:", exc)
|
||||
|
||||
return {"status": "OK"}
|
||||
|
||||
except Exception as e:
|
||||
await update_withdraw_link(link.id, **changesback)
|
||||
print(traceback.format_exc())
|
||||
return {"status": "ERROR", "reason": "Link not working"}
|
||||
|
||||
|
||||
|
|
@ -115,11 +159,13 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash
|
|||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.OK, detail="LNURL-withdraw not found."
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
||||
)
|
||||
|
||||
if link.is_spent:
|
||||
raise HTTPException(status_code=HTTPStatus.OK, detail="Withdraw is spent.")
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
||||
)
|
||||
|
||||
useslist = link.usescsv.split(",")
|
||||
found = False
|
||||
|
|
@ -127,15 +173,16 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash
|
|||
tohash = link.id + link.unique_hash + str(x)
|
||||
if id_unique_hash == shortuuid.uuid(name=tohash):
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.OK, detail="LNURL-withdraw not found."
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
||||
)
|
||||
|
||||
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
||||
withdrawResponse = {
|
||||
"tag": "withdrawRequest",
|
||||
"callback": url,
|
||||
"callback": url + "?id_unique_hash=" + id_unique_hash,
|
||||
"k1": link.k1,
|
||||
"minWithdrawable": link.min_withdrawable * 1000,
|
||||
"maxWithdrawable": link.max_withdrawable * 1000,
|
||||
|
|
|
|||
|
|
@ -108,3 +108,9 @@ async def m003_make_hash_check(db):
|
|||
);
|
||||
"""
|
||||
)
|
||||
|
||||
async def m004_webhook_url(db):
|
||||
"""
|
||||
Adds webhook_url
|
||||
"""
|
||||
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_url TEXT;")
|
||||
|
|
@ -15,6 +15,7 @@ class CreateWithdrawData(BaseModel):
|
|||
uses: int = Query(..., ge=1)
|
||||
wait_time: int = Query(..., ge=1)
|
||||
is_unique: bool
|
||||
webhook_url: str = Query(None)
|
||||
|
||||
|
||||
class WithdrawLink(BaseModel):
|
||||
|
|
@ -32,6 +33,7 @@ class WithdrawLink(BaseModel):
|
|||
used: int = Query(0)
|
||||
usescsv: str = Query(None)
|
||||
number: int = Query(0)
|
||||
webhook_url: str = Query(None)
|
||||
|
||||
@property
|
||||
def is_spent(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -179,7 +179,8 @@ new Vue({
|
|||
'max_withdrawable',
|
||||
'uses',
|
||||
'wait_time',
|
||||
'is_unique'
|
||||
'is_unique',
|
||||
'webhook_url'
|
||||
)
|
||||
)
|
||||
.then(function (response) {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@
|
|||
<code
|
||||
>{"title": <string>, "min_withdrawable": <integer>,
|
||||
"max_withdrawable": <integer>, "uses": <integer>,
|
||||
"wait_time": <integer>, "is_unique": <boolean>}</code
|
||||
"wait_time": <integer>, "is_unique": <boolean>,
|
||||
"webhook_url": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
|
|
@ -81,7 +82,7 @@
|
|||
>curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title":
|
||||
<string>, "min_withdrawable": <integer>,
|
||||
"max_withdrawable": <integer>, "uses": <integer>,
|
||||
"wait_time": <integer>, "is_unique": <boolean>}' -H
|
||||
"wait_time": <integer>, "is_unique": <boolean>, "webhook_url": <string>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
|
|
|
|||
10
lnbits/extensions/withdraw/templates/withdraw/csv.html
Normal file
10
lnbits/extensions/withdraw/templates/withdraw/csv.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor %} {% endblock %} {% block scripts %}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
data: function() {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,17 +1,12 @@
|
|||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/withdraw/static/js/index.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="simpleformDialog.show = true"
|
||||
>Quick vouchers</q-btn
|
||||
>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>Advanced withdraw link(s)</q-btn
|
||||
>
|
||||
<q-btn unelevated color="primary" @click="simpleformDialog.show = true">Quick vouchers</q-btn>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true">Advanced withdraw link(s)</q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
|
|
@ -25,14 +20,7 @@
|
|||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="sortedWithdrawLinks"
|
||||
row-key="id"
|
||||
:columns="withdrawLinksTable.columns"
|
||||
:pagination.sync="withdrawLinksTable.pagination"
|
||||
>
|
||||
<q-table dense flat :data="sortedWithdrawLinks" row-key="id" :columns="withdrawLinksTable.columns" :pagination.sync="withdrawLinksTable.pagination">
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
|
|
@ -41,6 +29,7 @@
|
|||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
|
|
@ -69,6 +58,17 @@
|
|||
target="_blank"
|
||||
><q-tooltip> embeddable image </q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="reorder"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/withdraw/csv/' + props.row.id"
|
||||
target="_blank"
|
||||
><q-tooltip> csv list </q-tooltip></q-btn
|
||||
>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
|
|
@ -82,6 +82,11 @@
|
|||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
||||
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
|
|
@ -101,8 +106,7 @@
|
|||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</template> {% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -129,101 +133,45 @@
|
|||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
<q-select filled dense emit-value v-model="formDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.title"
|
||||
type="text"
|
||||
label="Link title *"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.min_withdrawable"
|
||||
type="number"
|
||||
min="10"
|
||||
label="Min withdrawable (sat, at least 10) *"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.max_withdrawable"
|
||||
type="number"
|
||||
min="10"
|
||||
label="Max withdrawable (sat, at least 10) *"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.uses"
|
||||
type="number"
|
||||
:default="1"
|
||||
label="Amount of uses *"
|
||||
></q-input>
|
||||
<q-input filled dense v-model.trim="formDialog.data.title" type="text" label="Link title *"></q-input>
|
||||
<q-input filled dense v-model.number="formDialog.data.min_withdrawable" type="number" min="10" label="Min withdrawable (sat, at least 10) *"></q-input>
|
||||
<q-input filled dense v-model.number="formDialog.data.max_withdrawable" type="number" min="10" label="Max withdrawable (sat, at least 10) *"></q-input>
|
||||
<q-input filled dense v-model.number="formDialog.data.uses" type="number" max="250" :default="1" label="Amount of uses *"></q-input>
|
||||
<div class="row q-col-gutter-none">
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.wait_time"
|
||||
type="number"
|
||||
:default="1"
|
||||
label="Time between withdrawals *"
|
||||
>
|
||||
<q-input filled dense v-model.number="formDialog.data.wait_time" type="number" :default="1" label="Time between withdrawals *">
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-4 q-pl-xs">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.secondMultiplier"
|
||||
:options="formDialog.secondMultiplierOptions"
|
||||
>
|
||||
<q-select filled dense v-model="formDialog.secondMultiplier" :options="formDialog.secondMultiplierOptions">
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.webhook_url"
|
||||
type="text"
|
||||
label="Webhook URL (optional)"
|
||||
hint="A URL to be called whenever this link gets used."
|
||||
></q-input>
|
||||
<q-list>
|
||||
<q-item tag="label" class="rounded-borders">
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
v-model="formDialog.data.is_unique"
|
||||
color="primary"
|
||||
></q-checkbox>
|
||||
<q-checkbox v-model="formDialog.data.is_unique" color="primary"></q-checkbox>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
>Use unique withdraw QR codes to reduce
|
||||
`assmilking`</q-item-label
|
||||
>
|
||||
<q-item-label caption
|
||||
>This is recommended if you are sharing the links on social
|
||||
media or print QR codes.</q-item-label
|
||||
>
|
||||
<q-item-label>Use unique withdraw QR codes to reduce `assmilking`
|
||||
</q-item-label>
|
||||
<q-item-label caption>This is recommended if you are sharing the links on social media or print QR codes.</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update withdraw link</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
<q-btn v-if="formDialog.data.id" unelevated color="primary" type="submit">Update withdraw link</q-btn>
|
||||
<q-btn v-else unelevated color="primary" :disable="
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.title == null ||
|
||||
(formDialog.data.min_withdrawable == null || formDialog.data.min_withdrawable < 1) ||
|
||||
|
|
@ -233,67 +181,29 @@
|
|||
formDialog.data.max_withdrawable < formDialog.data.min_withdrawable
|
||||
) ||
|
||||
formDialog.data.uses == null ||
|
||||
formDialog.data.wait_time == null"
|
||||
type="submit"
|
||||
>Create withdraw link</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
formDialog.data.wait_time == null" type="submit">Create withdraw link</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog
|
||||
v-model="simpleformDialog.show"
|
||||
position="top"
|
||||
@hide="simplecloseFormDialog"
|
||||
>
|
||||
<q-dialog v-model="simpleformDialog.show" position="top" @hide="simplecloseFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="simplesendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="simpleformDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
<q-select filled dense emit-value v-model="simpleformDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="simpleformDialog.data.max_withdrawable"
|
||||
type="number"
|
||||
min="10"
|
||||
label="Withdraw amount per voucher (sat, at least 10)"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="simpleformDialog.data.uses"
|
||||
type="number"
|
||||
:default="1"
|
||||
label="Number of vouchers"
|
||||
></q-input>
|
||||
<q-input filled dense v-model.number="simpleformDialog.data.max_withdrawable" type="number" min="10" label="Withdraw amount per voucher (sat, at least 10)"></q-input>
|
||||
<q-input filled dense v-model.number="simpleformDialog.data.uses" type="number" max="250" :default="1" label="Number of vouchers"></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
<q-btn unelevated color="primary" :disable="
|
||||
simpleformDialog.data.wallet == null ||
|
||||
|
||||
simpleformDialog.data.max_withdrawable == null ||
|
||||
simpleformDialog.data.max_withdrawable < 1 ||
|
||||
simpleformDialog.data.uses == null"
|
||||
type="submit"
|
||||
>Create vouchers</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
simpleformDialog.data.uses == null" type="submit">Create vouchers</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
|
|
@ -302,19 +212,12 @@
|
|||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
<qrcode :value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl" :options="{width: 800}" class="rounded-borders"></qrcode>
|
||||
{% raw %}
|
||||
</q-responsive>
|
||||
<p style="word-break: break-all">
|
||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||
<strong>Unique:</strong> {{ qrCodeDialog.data.is_unique }}<span
|
||||
v-if="qrCodeDialog.data.is_unique"
|
||||
class="text-deep-purple"
|
||||
>
|
||||
<strong>Unique:</strong> {{ qrCodeDialog.data.is_unique }}<span v-if="qrCodeDialog.data.is_unique" class="text-deep-purple">
|
||||
(QR code will change after each withdrawal)</span
|
||||
><br />
|
||||
<strong>Max. withdrawable:</strong> {{
|
||||
|
|
|
|||
|
|
@ -102,3 +102,38 @@ async def print_qr(request: Request, link_id):
|
|||
return withdraw_renderer().TemplateResponse(
|
||||
"withdraw/print_qr.html", {"request": request, "link": linked, "unique": True}
|
||||
)
|
||||
|
||||
|
||||
@withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse)
|
||||
async def print_qr(request: Request, link_id):
|
||||
link = await get_withdraw_link(link_id)
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||
)
|
||||
# response.status_code = HTTPStatus.NOT_FOUND
|
||||
# return "Withdraw link does not exist."
|
||||
|
||||
if link.uses == 0:
|
||||
|
||||
return withdraw_renderer().TemplateResponse(
|
||||
"withdraw/csv.html",
|
||||
{"request": request, "link": link.dict(), "unique": False},
|
||||
)
|
||||
links = []
|
||||
count = 0
|
||||
|
||||
for x in link.usescsv.split(","):
|
||||
linkk = await get_withdraw_link(link_id, count)
|
||||
if not linkk:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
||||
)
|
||||
links.append(str(linkk.lnurl(request)))
|
||||
count = count + 1
|
||||
page_link = list(chunks(links, 2))
|
||||
linked = list(chunks(page_link, 5))
|
||||
|
||||
return withdraw_renderer().TemplateResponse(
|
||||
"withdraw/csv.html", {"request": request, "link": linked, "unique": True}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ async def api_link_create_or_update(
|
|||
link_id: str = None,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
):
|
||||
if data.uses > 250:
|
||||
raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if data.min_withdrawable < 1:
|
||||
raise HTTPException(
|
||||
detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if data.max_withdrawable < data.min_withdrawable:
|
||||
raise HTTPException(
|
||||
detail="`max_withdrawable` needs to be at least `min_withdrawable`.",
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ from typing import Any, List, NamedTuple, Optional
|
|||
import jinja2
|
||||
import shortuuid # type: ignore
|
||||
|
||||
import lnbits.settings as settings
|
||||
from lnbits.jinja2_templating import Jinja2Templates
|
||||
from lnbits.requestvars import g
|
||||
|
||||
import lnbits.settings as settings
|
||||
|
||||
|
||||
class Extension(NamedTuple):
|
||||
code: str
|
||||
|
|
@ -26,7 +25,9 @@ class Extension(NamedTuple):
|
|||
class ExtensionManager:
|
||||
def __init__(self):
|
||||
self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS
|
||||
self._admin_only: List[str] = [x.strip(' ') for x in settings.LNBITS_ADMIN_EXTENSIONS]
|
||||
self._admin_only: List[str] = [
|
||||
x.strip(" ") for x in settings.LNBITS_ADMIN_EXTENSIONS
|
||||
]
|
||||
self._extension_folders: List[str] = [
|
||||
x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions"))
|
||||
][0]
|
||||
|
|
@ -160,6 +161,10 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
|
|||
["lnbits/templates", "lnbits/core/templates", *additional_folders]
|
||||
)
|
||||
)
|
||||
|
||||
if settings.LNBITS_AD_SPACE:
|
||||
t.env.globals["AD_SPACE"] = settings.LNBITS_AD_SPACE
|
||||
t.env.globals["HIDE_API"] = settings.LNBITS_HIDE_API
|
||||
t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE
|
||||
t.env.globals["LNBITS_DENOMINATION"] = settings.LNBITS_DENOMINATION
|
||||
t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE
|
||||
|
|
@ -167,6 +172,8 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
|
|||
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS
|
||||
t.env.globals["LNBITS_VERSION"] = settings.LNBITS_COMMIT
|
||||
t.env.globals["EXTENSIONS"] = get_valid_extensions()
|
||||
if settings.LNBITS_CUSTOM_LOGO:
|
||||
t.env.globals["USE_CUSTOM_LOGO"] = settings.LNBITS_CUSTOM_LOGO
|
||||
|
||||
if settings.DEBUG:
|
||||
t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored())
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import subprocess
|
||||
import importlib
|
||||
|
||||
from environs import Env # type: ignore
|
||||
import subprocess
|
||||
from os import path
|
||||
from typing import List
|
||||
|
||||
from environs import Env # type: ignore
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
|
@ -29,11 +28,15 @@ LNBITS_ALLOWED_USERS: List[str] = env.list(
|
|||
"LNBITS_ALLOWED_USERS", default=[], subcast=str
|
||||
)
|
||||
LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str)
|
||||
LNBITS_ADMIN_EXTENSIONS: List[str] = env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str)
|
||||
LNBITS_ADMIN_EXTENSIONS: List[str] = env.list(
|
||||
"LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str
|
||||
)
|
||||
LNBITS_DISABLED_EXTENSIONS: List[str] = env.list(
|
||||
"LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str
|
||||
)
|
||||
|
||||
LNBITS_AD_SPACE = env.list("LNBITS_AD_SPACE", default=[])
|
||||
LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False)
|
||||
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
|
||||
LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats")
|
||||
LNBITS_SITE_TAGLINE = env.str(
|
||||
|
|
@ -45,6 +48,7 @@ LNBITS_THEME_OPTIONS: List[str] = env.list(
|
|||
default="classic, flamingo, mint, salvador, monochrome, autumn",
|
||||
subcast=str,
|
||||
)
|
||||
LNBITS_CUSTOM_LOGO = env.str("LNBITS_CUSTOM_LOGO", default="")
|
||||
|
||||
WALLET = wallet_class()
|
||||
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
|
||||
|
|
|
|||
BIN
lnbits/static/images/templatead.png
Normal file
BIN
lnbits/static/images/templatead.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
|
|
@ -1,63 +1,14 @@
|
|||
$themes: (
|
||||
'classic': (
|
||||
primary: #673ab7,
|
||||
secondary: #9c27b0,
|
||||
dark: #1f2234,
|
||||
info: #333646,
|
||||
marginal-bg: #1f2234,
|
||||
marginal-text: #fff
|
||||
),
|
||||
'bitcoin': (
|
||||
primary: #ff9853,
|
||||
secondary: #ff7353,
|
||||
dark: #2d293b,
|
||||
info: #333646,
|
||||
marginal-bg: #2d293b,
|
||||
marginal-text: #fff
|
||||
),
|
||||
'mint': (
|
||||
primary: #3ab77d,
|
||||
secondary: #27b065,
|
||||
dark: #1f342b,
|
||||
info: #334642,
|
||||
marginal-bg: #1f342b,
|
||||
marginal-text: #fff
|
||||
),
|
||||
'autumn': (
|
||||
primary: #b7763a,
|
||||
secondary: #b07927,
|
||||
dark: #34291f,
|
||||
info: #463f33,
|
||||
marginal-bg: #342a1f,
|
||||
marginal-text: rgb(255, 255, 255)
|
||||
),
|
||||
'flamingo': (
|
||||
primary: #d11d53,
|
||||
secondary: #db3e6d,
|
||||
dark: #803a45,
|
||||
info: #ec7599,
|
||||
marginal-bg: #803a45,
|
||||
marginal-text: rgb(255, 255, 255)
|
||||
),
|
||||
'monochrome': (
|
||||
primary: #494949,
|
||||
secondary: #6b6b6b,
|
||||
dark: #000,
|
||||
info: rgb(39, 39, 39),
|
||||
marginal-bg: #000,
|
||||
marginal-text: rgb(255, 255, 255)
|
||||
)
|
||||
);
|
||||
|
||||
@each $theme, $colors in $themes {
|
||||
@each $name, $color in $colors {
|
||||
$themes: ( 'classic': ( primary: #673ab7, secondary: #9c27b0, dark: #1f2234, info: #333646, marginal-bg: #1f2234, marginal-text: #fff), 'bitcoin': ( primary: #ff9853, secondary: #ff7353, dark: #2d293b, info: #333646, marginal-bg: #2d293b, marginal-text: #fff), 'freedom': ( primary: #e22156, secondary: #b91a45, dark: #0a0a0a, info: #1b1b1b, marginal-bg: #2d293b, marginal-text: #fff), 'mint': ( primary: #3ab77d, secondary: #27b065, dark: #1f342b, info: #334642, marginal-bg: #1f342b, marginal-text: #fff), 'autumn': ( primary: #b7763a, secondary: #b07927, dark: #34291f, info: #463f33, marginal-bg: #342a1f, marginal-text: rgb(255, 255, 255)), 'flamingo': ( primary: #d11d53, secondary: #db3e6d, dark: #803a45, info: #ec7599, marginal-bg: #803a45, marginal-text: rgb(255, 255, 255)), 'monochrome': ( primary: #494949, secondary: #6b6b6b, dark: #000, info: rgb(39, 39, 39), marginal-bg: #000, marginal-text: rgb(255, 255, 255)));
|
||||
@each $theme,
|
||||
$colors in $themes {
|
||||
@each $name,
|
||||
$color in $colors {
|
||||
@if $name=='dark' {
|
||||
[data-theme='#{$theme}'] .q-drawer--dark,
|
||||
body[data-theme='#{$theme}'].body--dark,
|
||||
[data-theme='#{$theme}'] .q-menu--dark {
|
||||
background: $color !important;
|
||||
}
|
||||
|
||||
/* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
|
||||
// set a darker body bg for all themes, when in "dark mode"
|
||||
body[data-theme='#{$theme}'].body--dark {
|
||||
|
|
@ -73,7 +24,8 @@ $themes: (
|
|||
}
|
||||
}
|
||||
[data-theme='#{$theme}'] {
|
||||
@each $name, $color in $colors {
|
||||
@each $name,
|
||||
$color in $colors {
|
||||
.bg-#{$name} {
|
||||
background: $color !important;
|
||||
}
|
||||
|
|
@ -83,6 +35,15 @@ $themes: (
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme='freedom'] .q-drawer--dark {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
[data-theme='freedom'] .q-header {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
[data-theme='salvador'] .q-drawer--dark {
|
||||
background: #242424 !important;
|
||||
}
|
||||
|
|
@ -119,7 +80,6 @@ body.body--dark .q-field--error {
|
|||
padding-bottom: 5px !important;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
|
||||
&.q-item--active {
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
|
|
@ -136,7 +96,6 @@ body.body--dark .q-field--error {
|
|||
.q-table__bottom {
|
||||
padding-left: 6px !important;
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child,
|
||||
.q-table__bottom {
|
||||
|
|
@ -150,13 +109,11 @@ a.inherit {
|
|||
}
|
||||
|
||||
// QR video
|
||||
|
||||
video {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
// Material icons font
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ async def perform_balance_checks():
|
|||
|
||||
|
||||
async def invoice_callback_dispatcher(checking_id: str):
|
||||
payment = await get_standalone_payment(checking_id)
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if payment and payment.is_in:
|
||||
await payment.set_pending(False)
|
||||
for send_chan in invoice_listeners:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
{% endfor %}
|
||||
<!---->
|
||||
<link rel="stylesheet" type="text/css" href="/static/css/base.css" />
|
||||
|
||||
{% block styles %}{% endblock %}
|
||||
<title>{% block title %}{{ SITE_TITLE }}{% endblock %}</title>
|
||||
<meta charset="utf-8" />
|
||||
|
|
@ -35,10 +34,12 @@
|
|||
{% endblock %}
|
||||
<q-toolbar-title>
|
||||
<q-btn flat no-caps dense size="lg" type="a" href="/">
|
||||
{% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{
|
||||
SITE_TITLE }} {% else %} <strong>LN</strong>bits {% endif %} {%
|
||||
endblock %}</q-btn
|
||||
>
|
||||
{% block toolbar_title %} {% if USE_CUSTOM_LOGO %}
|
||||
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
|
||||
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else
|
||||
%}
|
||||
<strong>LN</strong>bits {% endif %} {%endif%} {% endblock %}
|
||||
</q-btn>
|
||||
</q-toolbar-title>
|
||||
{% block beta %}
|
||||
<q-badge color="yellow" text-color="black" class="q-mr-md">
|
||||
|
|
@ -118,6 +119,16 @@
|
|||
size="md"
|
||||
><q-tooltip>elSalvador</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('freedom')"
|
||||
dense
|
||||
flat
|
||||
@click="changeColor('freedom')"
|
||||
icon="format_color_fill"
|
||||
color="pink-13"
|
||||
size="md"
|
||||
><q-tooltip>Freedom</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-if="g.allowedThemes.includes('flamingo')"
|
||||
dense
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
{% extends "base.html" %} {% block beta %}{% endblock %} {% block drawer_toggle
|
||||
%}{% endblock %} {% block drawer %}{% endblock %} {% block toolbar_title %}
|
||||
<a href="/" class="inherit">
|
||||
{% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %}
|
||||
<strong>LN</strong>bits {% endif %}
|
||||
<a
|
||||
href="/"
|
||||
class="inherit q-btn q-btn-item non-selectable no-outline q-btn--flat q-btn--rectangle q-btn--actionable q-focusable q-hoverable q-btn--no-uppercase q-btn--wrap q-btn--dense q-btn--active"
|
||||
style="font-size: 20px"
|
||||
>
|
||||
{% if USE_CUSTOM_LOGO %}
|
||||
<img height="30px" alt="Logo" src="{{ USE_CUSTOM_LOGO }}" />
|
||||
{%else%} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %}
|
||||
<strong>LN</strong>bits {% endif %} {% endif %}
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ currencies = {
|
|||
"IMP": "Isle of Man Pound",
|
||||
"INR": "Indian Rupee",
|
||||
"IQD": "Iraqi Dinar",
|
||||
"IRT": "Iranian Toman",
|
||||
"ISK": "Icelandic Króna",
|
||||
"JEP": "Jersey Pound",
|
||||
"JMD": "Jamaican Dollar",
|
||||
|
|
@ -179,6 +180,12 @@ class Provider(NamedTuple):
|
|||
|
||||
|
||||
exchange_rate_providers = {
|
||||
"exir": Provider(
|
||||
"Exir",
|
||||
"exir.io",
|
||||
"https://api.exir.io/v1/ticker?symbol={from}-{to}",
|
||||
lambda data, replacements: data["last"],
|
||||
),
|
||||
"bitfinex": Provider(
|
||||
"Bitfinex",
|
||||
"bitfinex.com",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# flake8: noqa
|
||||
|
||||
from .void import VoidWallet
|
||||
from .clightning import CLightningWallet
|
||||
from .lndgrpc import LndWallet
|
||||
from .lntxbot import LntxbotWallet
|
||||
from .opennode import OpenNodeWallet
|
||||
from .lnpay import LNPayWallet
|
||||
from .eclair import EclairWallet
|
||||
from .fake import FakeWallet
|
||||
from .lnbits import LNbitsWallet
|
||||
from .lndrest import LndRestWallet
|
||||
from .lnpay import LNPayWallet
|
||||
from .lntxbot import LntxbotWallet
|
||||
from .opennode import OpenNodeWallet
|
||||
from .spark import SparkWallet
|
||||
from .fake import FakeWallet
|
||||
from .void import VoidWallet
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ class Wallet(ABC):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def pay_invoice(self, bolt11: str) -> Coroutine[None, None, PaymentResponse]:
|
||||
def pay_invoice(
|
||||
self, bolt11: str, fee_limit_msat: int
|
||||
) -> Coroutine[None, None, PaymentResponse]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from .base import (
|
|||
Unsupported,
|
||||
Wallet,
|
||||
)
|
||||
from lnbits import bolt11 as lnbits_bolt11
|
||||
|
||||
|
||||
def async_wrap(func):
|
||||
|
|
@ -31,8 +32,8 @@ def async_wrap(func):
|
|||
return run
|
||||
|
||||
|
||||
def _pay_invoice(ln, bolt11):
|
||||
return ln.pay(bolt11)
|
||||
def _pay_invoice(ln, payload):
|
||||
return ln.call("pay", payload)
|
||||
|
||||
|
||||
def _paid_invoices_stream(ln, last_pay_index):
|
||||
|
|
@ -102,10 +103,18 @@ class CLightningWallet(Wallet):
|
|||
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
|
||||
return InvoiceResponse(False, label, None, error_message)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
invoice = lnbits_bolt11.decode(bolt11)
|
||||
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
|
||||
|
||||
payload = {
|
||||
"bolt11": bolt11,
|
||||
"maxfeepercent": "{:.11}".format(fee_limit_percent),
|
||||
"exemptfee": 0, # so fee_limit_percent is applied even on payments with fee under 5000 millisatoshi (which is default value of exemptfee)
|
||||
}
|
||||
try:
|
||||
wrapped = async_wrap(_pay_invoice)
|
||||
r = await wrapped(self.ln, bolt11)
|
||||
r = await wrapped(self.ln, payload)
|
||||
except RpcError as exc:
|
||||
return PaymentResponse(False, None, 0, None, str(exc))
|
||||
|
||||
|
|
|
|||
200
lnbits/wallets/eclair.py
Normal file
200
lnbits/wallets/eclair.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import urllib.parse
|
||||
from os import getenv
|
||||
from typing import AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from websockets import connect
|
||||
from websockets.exceptions import (
|
||||
ConnectionClosed,
|
||||
ConnectionClosedError,
|
||||
ConnectionClosedOK,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
InvoiceResponse,
|
||||
PaymentResponse,
|
||||
PaymentStatus,
|
||||
StatusResponse,
|
||||
Wallet,
|
||||
)
|
||||
|
||||
|
||||
class EclairError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class EclairWallet(Wallet):
|
||||
def __init__(self):
|
||||
url = getenv("ECLAIR_URL")
|
||||
self.url = url[:-1] if url.endswith("/") else url
|
||||
|
||||
self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws"
|
||||
|
||||
passw = getenv("ECLAIR_PASS")
|
||||
encodedAuth = base64.b64encode(f":{passw}".encode("utf-8"))
|
||||
auth = str(encodedAuth, "utf-8")
|
||||
self.auth = {"Authorization": f"Basic {auth}"}
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.url}/usablebalances", headers=self.auth, timeout=40
|
||||
)
|
||||
try:
|
||||
data = r.json()
|
||||
except:
|
||||
return StatusResponse(
|
||||
f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
return StatusResponse(data["error"], 0)
|
||||
|
||||
return StatusResponse(None, data[0]["canSend"] * 1000)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: int,
|
||||
memo: Optional[str] = None,
|
||||
description_hash: Optional[bytes] = None,
|
||||
) -> InvoiceResponse:
|
||||
|
||||
data: Dict = {"amountMsat": amount * 1000}
|
||||
if description_hash:
|
||||
data["description_hash"] = description_hash.hex()
|
||||
else:
|
||||
data["description"] = memo or ""
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.url}/createinvoice", headers=self.auth, data=data, timeout=40
|
||||
)
|
||||
|
||||
if r.is_error:
|
||||
try:
|
||||
data = r.json()
|
||||
error_message = data["error"]
|
||||
except:
|
||||
error_message = r.text
|
||||
pass
|
||||
|
||||
return InvoiceResponse(False, None, None, error_message)
|
||||
|
||||
data = r.json()
|
||||
return InvoiceResponse(True, data["paymentHash"], data["serialized"], None)
|
||||
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.url}/payinvoice",
|
||||
headers=self.auth,
|
||||
data={"invoice": bolt11, "blocking": True},
|
||||
timeout=40,
|
||||
)
|
||||
|
||||
if "error" in r.json():
|
||||
try:
|
||||
data = r.json()
|
||||
error_message = data["error"]
|
||||
except:
|
||||
error_message = r.text
|
||||
pass
|
||||
return PaymentResponse(False, None, 0, None, error_message)
|
||||
|
||||
data = r.json()
|
||||
|
||||
checking_id = data["paymentHash"]
|
||||
preimage = data["paymentPreimage"]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.url}/getsentinfo",
|
||||
headers=self.auth,
|
||||
data={"paymentHash": checking_id},
|
||||
timeout=40,
|
||||
)
|
||||
|
||||
if "error" in r.json():
|
||||
try:
|
||||
data = r.json()
|
||||
error_message = data["error"]
|
||||
except:
|
||||
error_message = r.text
|
||||
pass
|
||||
return PaymentResponse(
|
||||
True, checking_id, 0, preimage, error_message
|
||||
) ## ?? is this ok ??
|
||||
|
||||
data = r.json()
|
||||
fees = [i["status"] for i in data]
|
||||
fee_msat = sum([i["feesPaid"] for i in fees])
|
||||
|
||||
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.url}/getreceivedinfo",
|
||||
headers=self.auth,
|
||||
data={"paymentHash": checking_id},
|
||||
)
|
||||
data = r.json()
|
||||
|
||||
if r.is_error or "error" in data:
|
||||
return PaymentStatus(None)
|
||||
|
||||
if data["status"]["type"] != "received":
|
||||
return PaymentStatus(False)
|
||||
|
||||
return PaymentStatus(True)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
url=f"{self.url}/getsentinfo",
|
||||
headers=self.auth,
|
||||
data={"paymentHash": checking_id},
|
||||
)
|
||||
|
||||
data = r.json()[0]
|
||||
|
||||
if r.is_error:
|
||||
return PaymentStatus(None)
|
||||
|
||||
if data["status"]["type"] != "sent":
|
||||
return PaymentStatus(False)
|
||||
|
||||
return PaymentStatus(True)
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
|
||||
try:
|
||||
async with connect(
|
||||
self.ws_url,
|
||||
extra_headers=[("Authorization", self.auth["Authorization"])],
|
||||
) as ws:
|
||||
while True:
|
||||
message = await ws.recv()
|
||||
message = json.loads(message)
|
||||
|
||||
if message and message["type"] == "payment-received":
|
||||
yield message["paymentHash"]
|
||||
|
||||
except (
|
||||
OSError,
|
||||
ConnectionClosedOK,
|
||||
ConnectionClosedError,
|
||||
ConnectionClosed,
|
||||
) as ose:
|
||||
print("OSE", ose)
|
||||
pass
|
||||
|
||||
print("lost connection to eclair's websocket, retrying in 5 seconds")
|
||||
await asyncio.sleep(5)
|
||||
|
|
@ -36,7 +36,13 @@ class FakeWallet(Wallet):
|
|||
"out": False,
|
||||
"amount": amount,
|
||||
"currency": "bc",
|
||||
"privkey": hashlib.pbkdf2_hmac('sha256', secret.encode("utf-8"), ("FakeWallet").encode("utf-8"), 2048, 32).hex(),
|
||||
"privkey": hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
secret.encode("utf-8"),
|
||||
("FakeWallet").encode("utf-8"),
|
||||
2048,
|
||||
32,
|
||||
).hex(),
|
||||
"memo": None,
|
||||
"description_hash": None,
|
||||
"description": "",
|
||||
|
|
@ -53,22 +59,29 @@ class FakeWallet(Wallet):
|
|||
data["tags_set"] = ["d"]
|
||||
data["memo"] = memo
|
||||
data["description"] = memo
|
||||
randomHash = data["privkey"][:6] + hashlib.sha256(
|
||||
str(random.getrandbits(256)).encode("utf-8")
|
||||
).hexdigest()[6:]
|
||||
randomHash = (
|
||||
data["privkey"][:6]
|
||||
+ hashlib.sha256(str(random.getrandbits(256)).encode("utf-8")).hexdigest()[
|
||||
6:
|
||||
]
|
||||
)
|
||||
data["paymenthash"] = randomHash
|
||||
payment_request = encode(data)
|
||||
checking_id = randomHash
|
||||
|
||||
return InvoiceResponse(True, checking_id, payment_request)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
invoice = decode(bolt11)
|
||||
if hasattr(invoice, 'checking_id') and invoice.checking_id[6:] == data["privkey"][:6]:
|
||||
if (
|
||||
hasattr(invoice, "checking_id")
|
||||
and invoice.checking_id[6:] == data["privkey"][:6]
|
||||
):
|
||||
return PaymentResponse(True, invoice.payment_hash, 0)
|
||||
else:
|
||||
return PaymentResponse(ok = False, error_message="Only internal invoices can be used!")
|
||||
|
||||
return PaymentResponse(
|
||||
ok=False, error_message="Only internal invoices can be used!"
|
||||
)
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
return PaymentStatus(False)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ class LNbitsWallet(Wallet):
|
|||
|
||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
url=f"{self.endpoint}/api/v1/payments",
|
||||
|
|
|
|||
|
|
@ -93,10 +93,11 @@ class LndWallet(Wallet):
|
|||
or getenv("LND_INVOICE_MACAROON")
|
||||
)
|
||||
|
||||
|
||||
encrypted_macaroon = getenv("LND_GRPC_MACAROON_ENCRYPTED")
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(encrypted_macaroon)
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||
encrypted_macaroon
|
||||
)
|
||||
self.macaroon = load_macaroon(macaroon)
|
||||
|
||||
cert = open(self.cert_path, "rb").read()
|
||||
|
|
@ -143,10 +144,10 @@ class LndWallet(Wallet):
|
|||
payment_request = str(resp.payment_request)
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
resp = await self.rpc.SendPayment(
|
||||
lnrpc.SendPaymentRequest(payment_request=bolt11)
|
||||
)
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
fee_limit_fixed = ln.FeeLimit(fixed=fee_limit_msat // 1000)
|
||||
req = ln.SendRequest(payment_request=bolt11, fee_limit=fee_limit_fixed)
|
||||
resp = await self.rpc.SendPaymentSync(req)
|
||||
|
||||
if resp.payment_error:
|
||||
return PaymentResponse(False, "", 0, None, resp.payment_error)
|
||||
|
|
|
|||
|
|
@ -39,11 +39,13 @@ class LndRestWallet(Wallet):
|
|||
|
||||
encrypted_macaroon = getenv("LND_REST_MACAROON_ENCRYPTED")
|
||||
if encrypted_macaroon:
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(encrypted_macaroon)
|
||||
macaroon = AESCipher(description="macaroon decryption").decrypt(
|
||||
encrypted_macaroon
|
||||
)
|
||||
self.macaroon = load_macaroon(macaroon)
|
||||
|
||||
self.auth = {"Grpc-Metadata-macaroon": self.macaroon}
|
||||
self.cert = getenv("LND_REST_CERT")
|
||||
self.cert = getenv("LND_REST_CERT", True)
|
||||
|
||||
async def status(self) -> StatusResponse:
|
||||
try:
|
||||
|
|
@ -97,15 +99,11 @@ class LndRestWallet(Wallet):
|
|||
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
async with httpx.AsyncClient(verify=self.cert) as client:
|
||||
# set the fee limit for the payment
|
||||
invoice = lnbits_bolt11.decode(bolt11)
|
||||
lnrpcFeeLimit = dict()
|
||||
if invoice.amount_msat > 1000_000:
|
||||
lnrpcFeeLimit["percent"] = "1" # in percent
|
||||
else:
|
||||
lnrpcFeeLimit["fixed"] = "10" # in sat
|
||||
lnrpcFeeLimit["fixed_msat"] = "{}".format(fee_limit_msat)
|
||||
|
||||
r = await client.post(
|
||||
url=f"{self.endpoint}/v1/channels/transactions",
|
||||
|
|
@ -162,6 +160,7 @@ class LndRestWallet(Wallet):
|
|||
|
||||
# for some reason our checking_ids are in base64 but the payment hashes
|
||||
# returned here are in hex, lnd is weird
|
||||
checking_id = checking_id.replace("_", "/")
|
||||
checking_id = base64.b64decode(checking_id).hex()
|
||||
|
||||
for p in r.json()["payments"]:
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class LNPayWallet(Wallet):
|
|||
|
||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.endpoint}/wallet/{self.wallet_key}/withdraw",
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ class LntxbotWallet(Wallet):
|
|||
data = r.json()
|
||||
return InvoiceResponse(True, data["payment_hash"], data["pay_req"], None)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.endpoint}/payinvoice",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import getpass
|
|||
BLOCK_SIZE = 16
|
||||
import getpass
|
||||
|
||||
|
||||
def load_macaroon(macaroon: str) -> str:
|
||||
"""Returns hex version of a macaroon encoded in base64 or the file path.
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ def load_macaroon(macaroon: str) -> str:
|
|||
pass
|
||||
return macaroon
|
||||
|
||||
|
||||
class AESCipher(object):
|
||||
"""This class is compatible with crypto-js/aes.js
|
||||
|
||||
|
|
@ -39,6 +41,7 @@ class AESCipher(object):
|
|||
AES.decrypt(encrypted, password).toString(Utf8);
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, key=None, description=""):
|
||||
self.key = key
|
||||
self.description = description + " "
|
||||
|
|
@ -47,7 +50,6 @@ class AESCipher(object):
|
|||
length = BLOCK_SIZE - (len(data) % BLOCK_SIZE)
|
||||
return data + (chr(length) * length).encode()
|
||||
|
||||
|
||||
def unpad(self, data):
|
||||
return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))]
|
||||
|
||||
|
|
@ -70,8 +72,7 @@ class AESCipher(object):
|
|||
return final_key[:output]
|
||||
|
||||
def decrypt(self, encrypted: str) -> str:
|
||||
"""Decrypts a string using AES-256-CBC.
|
||||
"""
|
||||
"""Decrypts a string using AES-256-CBC."""
|
||||
passphrase = self.passphrase
|
||||
encrypted = base64.b64decode(encrypted)
|
||||
assert encrypted[0:8] == b"Salted__"
|
||||
|
|
@ -92,7 +93,10 @@ class AESCipher(object):
|
|||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
aes = AES.new(key, AES.MODE_CBC, iv)
|
||||
return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message))).decode()
|
||||
return base64.b64encode(
|
||||
b"Salted__" + salt + aes.encrypt(self.pad(message))
|
||||
).decode()
|
||||
|
||||
|
||||
# if this file is executed directly, ask for a macaroon and encrypt it
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ class OpenNodeWallet(Wallet):
|
|||
payment_request = data["lightning_invoice"]["payreq"]
|
||||
return InvoiceResponse(True, checking_id, payment_request, None)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{self.endpoint}/v2/withdrawals",
|
||||
|
|
|
|||
|
|
@ -107,9 +107,12 @@ class SparkWallet(Wallet):
|
|||
|
||||
return InvoiceResponse(ok, checking_id, payment_request, error_message)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
try:
|
||||
r = await self.pay(bolt11)
|
||||
r = await self.pay(
|
||||
bolt11=bolt11,
|
||||
maxfee=fee_limit_msat,
|
||||
)
|
||||
except (SparkError, UnknownError) as exc:
|
||||
listpays = await self.listpays(bolt11)
|
||||
if listpays:
|
||||
|
|
@ -129,7 +132,9 @@ class SparkWallet(Wallet):
|
|||
if pay["status"] == "failed":
|
||||
return PaymentResponse(False, None, 0, None, str(exc))
|
||||
elif pay["status"] == "pending":
|
||||
return PaymentResponse(None, payment_hash, 0, None, None)
|
||||
return PaymentResponse(
|
||||
None, payment_hash, fee_limit_msat, None, None
|
||||
)
|
||||
elif pay["status"] == "complete":
|
||||
r = pay
|
||||
r["payment_preimage"] = pay["preimage"]
|
||||
|
|
@ -152,9 +157,11 @@ class SparkWallet(Wallet):
|
|||
|
||||
if not r or not r.get("invoices"):
|
||||
return PaymentStatus(None)
|
||||
if r["invoices"][0]["status"] == "unpaid":
|
||||
return PaymentStatus(False)
|
||||
|
||||
if r["invoices"][0]["status"] == "paid":
|
||||
return PaymentStatus(True)
|
||||
else:
|
||||
return PaymentStatus(False)
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
||||
# check if it's 32 bytes hex
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ class VoidWallet(Wallet):
|
|||
)
|
||||
return StatusResponse(None, 0)
|
||||
|
||||
async def pay_invoice(self, bolt11: str) -> PaymentResponse:
|
||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
||||
raise Unsupported("")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ def app():
|
|||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app):
|
||||
client = AsyncClient(app=app, base_url=f'http://{HOST}:{PORT}')
|
||||
client = AsyncClient(app=app, base_url=f"http://{HOST}:{PORT}")
|
||||
# yield and pass the client to the test
|
||||
yield client
|
||||
# close the async client after the test has finished
|
||||
|
|
|
|||
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