first
This commit is contained in:
commit
299007e990
29 changed files with 2887 additions and 0 deletions
252
.devcontainer/.env
Normal file
252
.devcontainer/.env
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
#For more information on .env files, their content and format: https://pypi.org/project/python-dotenv/
|
||||||
|
|
||||||
|
######################################
|
||||||
|
########### Admin Settings ###########
|
||||||
|
######################################
|
||||||
|
|
||||||
|
# Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available.
|
||||||
|
# Warning: Enabling this will make LNbits ignore most configurations in file. Only the
|
||||||
|
# configurations defined in `ReadOnlySettings` will still be read from the environment variables.
|
||||||
|
# The rest of the settings will be stored in your database and you will be able to change them
|
||||||
|
# only through the Admin UI.
|
||||||
|
# Disable this to make LNbits use this config file again.
|
||||||
|
LNBITS_ADMIN_UI=true
|
||||||
|
|
||||||
|
# Change theme
|
||||||
|
LNBITS_SITE_TITLE="LNBits_NWC_SP"
|
||||||
|
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||||
|
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
|
||||||
|
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||||
|
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||||
|
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||||
|
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
######################################
|
||||||
|
########## Funding Source ############
|
||||||
|
######################################
|
||||||
|
|
||||||
|
# which fundingsources are allowed in the admin ui
|
||||||
|
LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet"
|
||||||
|
|
||||||
|
LNBITS_BACKEND_WALLET_CLASS=FakeWallet
|
||||||
|
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||||
|
# just so you can see the UI before dealing with this file.
|
||||||
|
|
||||||
|
# How many times to retry connectiong to the Funding Source before defaulting to the VoidWallet
|
||||||
|
# FUNDING_SOURCE_MAX_RETRIES=4
|
||||||
|
|
||||||
|
# Invoice expiry for LND, CLN, Eclair, LNbits funding sources
|
||||||
|
LIGHTNING_INVOICE_EXPIRY=3600
|
||||||
|
|
||||||
|
# Set one of these blocks depending on the wallet kind you chose above:
|
||||||
|
|
||||||
|
# ClicheWallet
|
||||||
|
CLICHE_ENDPOINT=ws://127.0.0.1:12000
|
||||||
|
|
||||||
|
# SparkWallet
|
||||||
|
SPARK_URL=http://localhost:9737/rpc
|
||||||
|
SPARK_TOKEN=myaccesstoken
|
||||||
|
|
||||||
|
# CoreLightningWallet
|
||||||
|
CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||||
|
|
||||||
|
# CoreLightningRestWallet
|
||||||
|
CORELIGHTNING_REST_URL=http://127.0.0.1:8185/
|
||||||
|
CORELIGHTNING_REST_MACAROON="/path/to/clnrest/access.macaroon" # or BASE64/HEXSTRING
|
||||||
|
CORELIGHTNING_REST_CERT="/path/to/clnrest/tls.cert"
|
||||||
|
|
||||||
|
# LnbitsWallet
|
||||||
|
LNBITS_ENDPOINT=https://demo.lnbits.com
|
||||||
|
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||||
|
|
||||||
|
# LndWallet
|
||||||
|
LND_GRPC_ENDPOINT=127.0.0.1
|
||||||
|
LND_GRPC_PORT=10009
|
||||||
|
LND_GRPC_CERT="/home/bob/.lnd/tls.cert"
|
||||||
|
LND_GRPC_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
|
||||||
|
# To use an AES-encrypted macaroon, set
|
||||||
|
# LND_GRPC_MACAROON="eNcRyPtEdMaCaRoOn"
|
||||||
|
|
||||||
|
# LndRestWallet
|
||||||
|
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
||||||
|
LND_REST_CERT="/home/bob/.lnd/tls.cert"
|
||||||
|
LND_REST_MACAROON="/home/bob/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" # or HEXSTRING
|
||||||
|
# To use an AES-encrypted macaroon, set
|
||||||
|
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
||||||
|
|
||||||
|
# LNPayWallet
|
||||||
|
LNPAY_API_ENDPOINT=https://api.lnpay.co/v1/
|
||||||
|
# Secret API Key under developers tab
|
||||||
|
LNPAY_API_KEY=LNPAY_API_KEY
|
||||||
|
# Wallet Admin in Wallet Access Keys
|
||||||
|
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||||
|
|
||||||
|
# AlbyWallet
|
||||||
|
ALBY_API_ENDPOINT=https://api.getalby.com/
|
||||||
|
ALBY_ACCESS_TOKEN=ALBY_ACCESS_TOKEN
|
||||||
|
|
||||||
|
# ZBDWallet
|
||||||
|
ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
|
||||||
|
ZBD_API_KEY=ZBD_ACCESS_TOKEN
|
||||||
|
|
||||||
|
# PhoenixdWallet
|
||||||
|
PHOENIXD_API_ENDPOINT=http://localhost:9740/
|
||||||
|
PHOENIXD_API_PASSWORD=PHOENIXD_KEY
|
||||||
|
|
||||||
|
# OpenNodeWallet
|
||||||
|
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||||
|
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
|
||||||
|
|
||||||
|
# LnTipsWallet
|
||||||
|
# Enter /api in LightningTipBot to get your key
|
||||||
|
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||||
|
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||||
|
|
||||||
|
######################################
|
||||||
|
####### Auth Configurations ##########
|
||||||
|
######################################
|
||||||
|
# Secret Key: will default to the hash of the super user. It is strongly recommended that you set your own value.
|
||||||
|
AUTH_SECRET_KEY="secret"
|
||||||
|
AUTH_TOKEN_EXPIRE_MINUTES=525600
|
||||||
|
# Possible authorization methods: user-id-only, username-password, google-auth, github-auth, keycloak-auth
|
||||||
|
AUTH_ALLOWED_METHODS="user-id-only, username-password"
|
||||||
|
# Set this flag if HTTP is used for OAuth
|
||||||
|
# OAUTHLIB_INSECURE_TRANSPORT="1"
|
||||||
|
|
||||||
|
# Google OAuth Config
|
||||||
|
# Make sure that the authorized redirect URIs contain https://{domain}/api/v1/auth/google/token
|
||||||
|
GOOGLE_CLIENT_ID=""
|
||||||
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# GitHub OAuth Config
|
||||||
|
# Make sure that the authorization callback URL is set to https://{domain}/api/v1/auth/github/token
|
||||||
|
GITHUB_CLIENT_ID=""
|
||||||
|
GITHUB_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# Keycloak OAuth Config
|
||||||
|
# Make sure that the valid redirect URIs contain https://{domain}/api/v1/auth/keycloak/token
|
||||||
|
KEYCLOAK_CLIENT_ID=""
|
||||||
|
KEYCLOAK_CLIENT_SECRET=""
|
||||||
|
KEYCLOAK_DISCOVERY_URL=""
|
||||||
|
|
||||||
|
|
||||||
|
######################################
|
||||||
|
|
||||||
|
# uvicorn variable, uncomment to allow https behind a proxy
|
||||||
|
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||||
|
# http://docs.lnbits.org/guide/installation.html#running-behind-an-apache2-reverse-proxy-over-https
|
||||||
|
# FORWARDED_ALLOW_IPS="*"
|
||||||
|
|
||||||
|
# Server security, rate limiting ips, blocked ips, allowed ips
|
||||||
|
LNBITS_RATE_LIMIT_NO="200"
|
||||||
|
LNBITS_RATE_LIMIT_UNIT="minute"
|
||||||
|
LNBITS_ALLOWED_IPS=""
|
||||||
|
LNBITS_BLOCKED_IPS=""
|
||||||
|
|
||||||
|
# Allow users and admins by user IDs (comma separated list)
|
||||||
|
# if set new users will not be able to create accounts
|
||||||
|
LNBITS_ALLOWED_USERS=""
|
||||||
|
LNBITS_ADMIN_USERS=""
|
||||||
|
# ID of the super user. The user ID must exist.
|
||||||
|
# SUPER_USER=""
|
||||||
|
|
||||||
|
# Extensions only admin can access
|
||||||
|
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
|
||||||
|
|
||||||
|
# Start LNbits core only. The extensions are not loaded.
|
||||||
|
# LNBITS_EXTENSIONS_DEACTIVATE_ALL=true
|
||||||
|
|
||||||
|
# Disable account creation for new users
|
||||||
|
# LNBITS_ALLOW_NEW_ACCOUNTS=false
|
||||||
|
|
||||||
|
# Enable Node Management without activating the LNBITS Admin GUI
|
||||||
|
# by setting the following variables to true.
|
||||||
|
LNBITS_NODE_UI=false
|
||||||
|
LNBITS_PUBLIC_NODE_UI=false
|
||||||
|
# Enabling the transactions tab can cause crashes on large Core Lightning nodes.
|
||||||
|
LNBITS_NODE_UI_TRANSACTIONS=false
|
||||||
|
|
||||||
|
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||||
|
|
||||||
|
# Ad space description
|
||||||
|
# LNBITS_AD_SPACE_TITLE="Supported by"
|
||||||
|
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
|
||||||
|
# LNBITS_AD_SPACE="https://shop.lnbits.com/;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-light.png;https://raw.githubusercontent.com/lnbits/lnbits/main/lnbits/static/images/lnbits-shop-dark.png"
|
||||||
|
# LNBITS_SHOW_HOME_PAGE_ELEMENTS=true # if set to true, the ad space will be displayed on the home page
|
||||||
|
# LNBITS_CUSTOM_BADGE="USE WITH CAUTION - LNbits wallet is still in BETA"
|
||||||
|
# LNBITS_CUSTOM_BADGE_COLOR="warning"
|
||||||
|
|
||||||
|
# Hides wallet api, extensions can choose to honor
|
||||||
|
LNBITS_HIDE_API=false
|
||||||
|
|
||||||
|
# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json"
|
||||||
|
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
|
||||||
|
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Path where extensions will be installed (defaults to `./lnbits/`).
|
||||||
|
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
|
||||||
|
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
|
||||||
|
|
||||||
|
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
|
||||||
|
# The extension must be removed from this list in order to not be re-installed.
|
||||||
|
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
||||||
|
|
||||||
|
# Database: to use SQLite, specify LNBITS_DATA_FOLDER
|
||||||
|
# to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://...
|
||||||
|
# to use CockroachDB, specify LNBITS_DATABASE_URL=cockroachdb://...
|
||||||
|
# for both PostgreSQL and CockroachDB, you'll need to install
|
||||||
|
# psycopg2 as an additional dependency
|
||||||
|
LNBITS_DATA_FOLDER="./data"
|
||||||
|
# LNBITS_DATABASE_URL="postgres://user:password@host:port/databasename"
|
||||||
|
|
||||||
|
# the service fee (in percent)
|
||||||
|
LNBITS_SERVICE_FEE=0.0
|
||||||
|
# the wallet where fees go to
|
||||||
|
# LNBITS_SERVICE_FEE_WALLET=
|
||||||
|
# the maximum fee per transaction (in satoshis)
|
||||||
|
# LNBITS_SERVICE_FEE_MAX=1000
|
||||||
|
# disable fees for internal transactions
|
||||||
|
# LNBITS_SERVICE_FEE_IGNORE_INTERNAL=true
|
||||||
|
|
||||||
|
# value in millisats
|
||||||
|
LNBITS_RESERVE_FEE_MIN=2000
|
||||||
|
# value in percent
|
||||||
|
LNBITS_RESERVE_FEE_PERCENT=1.0
|
||||||
|
|
||||||
|
# limit the maximum balance for each wallet
|
||||||
|
# throw an error if the wallet attempts to create a new invoice
|
||||||
|
|
||||||
|
# LNBITS_WALLET_LIMIT_MAX_BALANCE=1000000
|
||||||
|
# LNBITS_WALLET_LIMIT_DAILY_MAX_WITHDRAW=1000000
|
||||||
|
# LNBITS_WALLET_LIMIT_SECS_BETWEEN_TRANS=60
|
||||||
|
|
||||||
|
# Limit fiat currencies allowed to see in UI
|
||||||
|
# LNBITS_ALLOWED_CURRENCIES="EUR, USD"
|
||||||
|
|
||||||
|
######################################
|
||||||
|
###### Logging and Development #######
|
||||||
|
######################################
|
||||||
|
|
||||||
|
DEBUG=false
|
||||||
|
DEBUG_DATABASE=false
|
||||||
|
BUNDLE_ASSETS=true
|
||||||
|
|
||||||
|
# logging into LNBITS_DATA_FOLDER/logs/
|
||||||
|
ENABLE_LOG_TO_FILE=true
|
||||||
|
|
||||||
|
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
|
||||||
|
LOG_ROTATION="100 MB"
|
||||||
|
LOG_RETENTION="3 months"
|
||||||
|
|
||||||
|
# for database cleanup commands
|
||||||
|
# CLEANUP_WALLETS_DAYS=90
|
||||||
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "lnbits_nwc_provider",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/python:1-3.9-bullseye",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers-contrib/features/poetry:2": {
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
"mounts": [
|
||||||
|
"source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind",
|
||||||
|
"source=${localWorkspaceFolder}/.devcontainer/setup.sh,target=/setup.sh,type=bind"
|
||||||
|
],
|
||||||
|
"postCreateCommand": "/bin/bash /setup.sh ${containerWorkspaceFolder}",
|
||||||
|
"postStartCommand": "/bin/bash /start-lnbits.sh",
|
||||||
|
"forwardPorts": [
|
||||||
|
5000
|
||||||
|
],
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {
|
||||||
|
"python.pythonPath": "/opt/python/bin/python3.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
.devcontainer/setup.sh
Normal file
23
.devcontainer/setup.sh
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/bin/bash
|
||||||
|
CONTAINER_WORKSPACE_FOLDER=$1
|
||||||
|
sudo apt update -y
|
||||||
|
sudo apt install -y python3.9-distutils curl
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x -o /tmp/nodesource_setup.sh
|
||||||
|
sudo bash /tmp/nodesource_setup.sh
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
cd ..
|
||||||
|
if [ ! -d lnbits ] ; then
|
||||||
|
sudo git clone https://github.com/lnbits/lnbits.git;
|
||||||
|
fi
|
||||||
|
sudo chown 1000:1000 -Rvf lnbits
|
||||||
|
cd lnbits
|
||||||
|
git checkout 0.12.8
|
||||||
|
poetry env use python3.9
|
||||||
|
VENV_PATH=$(poetry env info -p)
|
||||||
|
sudo ln -s $VENV_PATH /opt/python
|
||||||
|
make bundle
|
||||||
|
poetry install --no-interaction
|
||||||
|
mkdir -p lnbits/extensions/
|
||||||
|
ln -s $CONTAINER_WORKSPACE_FOLDER lnbits/extensions/nwcprovider
|
||||||
|
|
||||||
|
|
||||||
4
.devcontainer/start.sh
Normal file
4
.devcontainer/start.sh
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
ln -s $PWD/.devcontainer/.env ../lnbits/.env
|
||||||
|
cd ../lnbits
|
||||||
|
poetry run lnbits
|
||||||
53
.github/xworkflows/main.yml
vendored
Normal file
53
.github/xworkflows/main.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Create github release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
gh release create "$tag" --generate-notes
|
||||||
|
pullrequest:
|
||||||
|
needs: [release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repository: lnbits/lnbits-extensions
|
||||||
|
path: './lnbits-extensions'
|
||||||
|
|
||||||
|
- name: setup git user
|
||||||
|
run: |
|
||||||
|
git config --global user.name "alan"
|
||||||
|
git config --global user.email "alan@lnbits.com"
|
||||||
|
- name: Create pull request in extensions repo
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repo_name: "${{ github.event.repository.name }}"
|
||||||
|
tag: "${{ github.ref_name }}"
|
||||||
|
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
|
||||||
|
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
|
||||||
|
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
|
||||||
|
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
|
||||||
|
run: |
|
||||||
|
cd lnbits-extensions
|
||||||
|
git checkout -b $branch
|
||||||
|
# if there is another open PR
|
||||||
|
git pull origin $branch || echo "branch does not exist"
|
||||||
|
sh util.sh update_extension $repo_name $tag
|
||||||
|
git add -A
|
||||||
|
git commit -am "$title"
|
||||||
|
git push origin $branch
|
||||||
|
# check if pr exists before creating it
|
||||||
|
gh config set pager cat
|
||||||
|
check=$(gh pr list -H $branch | wc -l)
|
||||||
|
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions
|
||||||
53
.github/xworkflows/release.yml
vendored
Normal file
53
.github/xworkflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Create github release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
gh release create "$tag" --generate-notes
|
||||||
|
pullrequest:
|
||||||
|
needs: [release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repository: lnbits/lnbits-extensions
|
||||||
|
path: './lnbits-extensions'
|
||||||
|
|
||||||
|
- name: setup git user
|
||||||
|
run: |
|
||||||
|
git config --global user.name "alan"
|
||||||
|
git config --global user.email "alan@lnbits.com"
|
||||||
|
- name: Create pull request in extensions repo
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.EXT_GITHUB }}
|
||||||
|
repo_name: "${{ github.event.repository.name }}"
|
||||||
|
tag: "${{ github.ref_name }}"
|
||||||
|
branch: "update-${{ github.event.repository.name }}-${{ github.ref_name }}"
|
||||||
|
title: "[UPDATE] ${{ github.event.repository.name }} to ${{ github.ref_name }}"
|
||||||
|
body: "https://github.com/lnbits/${{ github.event.repository.name }}/releases/${{ github.ref_name }}"
|
||||||
|
archive: "https://github.com/lnbits/${{ github.event.repository.name }}/archive/refs/tags/${{ github.ref_name }}.zip"
|
||||||
|
run: |
|
||||||
|
cd lnbits-extensions
|
||||||
|
git checkout -b $branch
|
||||||
|
# if there is another open PR
|
||||||
|
git pull origin $branch || echo "branch does not exist"
|
||||||
|
sh util.sh update_extension $repo_name $tag
|
||||||
|
git add -A
|
||||||
|
git commit -am "$title"
|
||||||
|
git push origin $branch
|
||||||
|
# check if pr exists before creating it
|
||||||
|
gh config set pager cat
|
||||||
|
check=$(gh pr list -H $branch | wc -l)
|
||||||
|
test $check -ne 0 || gh pr create --title "$title" --body "$body" --repo lnbits/lnbits-extensions
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
__pycache__
|
||||||
54
LICENSE
Normal file
54
LICENSE
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
> Use any license you like, its your extension.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DON'T BE A DICK PUBLIC LICENSE
|
||||||
|
|
||||||
|
> Version 1.1, December 2016
|
||||||
|
|
||||||
|
> Copyright (C) 2024 Alan Bits
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim or modified
|
||||||
|
copies of this license document.
|
||||||
|
|
||||||
|
> DON'T BE A DICK PUBLIC LICENSE
|
||||||
|
> TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
1. Do whatever you like with the original work, just don't be a dick.
|
||||||
|
|
||||||
|
Being a dick includes - but is not limited to - the following instances:
|
||||||
|
|
||||||
|
1a. Outright copyright infringement - Don't just copy this and change the name.
|
||||||
|
1b. Selling the unmodified original with no work done what-so-ever, that's REALLY being a dick.
|
||||||
|
1c. Modifying the original work to contain hidden harmful content. That would make you a PROPER dick.
|
||||||
|
|
||||||
|
2. If you become rich through modifications, related works/services, or supporting the original work,
|
||||||
|
share the love. Only a dick would make loads off this work and not buy the original work's
|
||||||
|
creator(s) a pint.
|
||||||
|
|
||||||
|
3. Code is provided with no warranty. Using somebody else's code and bitching when it goes wrong makes
|
||||||
|
you a DONKEY dick. Fix the problem yourself. A non-dick would submit the fix back.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# MIT License
|
||||||
|
|
||||||
|
> Copyright (c) 2024 Alan Bits
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
497
NWCServiceProvider.py
Normal file
497
NWCServiceProvider.py
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Dict
|
||||||
|
import secp256k1
|
||||||
|
from loguru import logger
|
||||||
|
from lnbits.settings import settings
|
||||||
|
import time
|
||||||
|
import websockets
|
||||||
|
from Cryptodome import Random
|
||||||
|
from Cryptodome.Cipher import AES
|
||||||
|
import base64
|
||||||
|
import random
|
||||||
|
from typing import Union, List, Callable, Tuple
|
||||||
|
from lnbits.app import settings
|
||||||
|
from lnbits.helpers import encrypt_internal_message
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
class MainSubscription:
|
||||||
|
def __init__(self):
|
||||||
|
self.requests_sub_id = None
|
||||||
|
self.responses_sub_id = None
|
||||||
|
self.requests_eose = False
|
||||||
|
self.responses_eose = False
|
||||||
|
self.events:Dict[str, Dict] = {}
|
||||||
|
self.responses:List[str] = []
|
||||||
|
|
||||||
|
def getStale(self) -> List[Dict]:
|
||||||
|
pending_events = []
|
||||||
|
for [id, event] in self.events.items():
|
||||||
|
if not id in self.responses:
|
||||||
|
pending_events.append(event)
|
||||||
|
return pending_events
|
||||||
|
|
||||||
|
def registerResponse(self, event_id:str):
|
||||||
|
if not event_id in self.responses:
|
||||||
|
self.responses.append(event_id)
|
||||||
|
|
||||||
|
|
||||||
|
class NWCServiceProvider:
|
||||||
|
def __init__(self, private_key:str=None, relay:str=None):
|
||||||
|
if not relay: # Connect to nostrclient
|
||||||
|
relay = "nostrclient"
|
||||||
|
if relay == "nostrclient":
|
||||||
|
relay=f"ws://localhost:{settings.port}/nostrclient/api/v1/relay"
|
||||||
|
elif relay == "nostrclient:private":
|
||||||
|
relay_endpoint = encrypt_internal_message("relay")
|
||||||
|
relay=f"ws://localhost:{settings.port}/nostrclient/api/v1/{relay_endpoint}"
|
||||||
|
self.relay = relay
|
||||||
|
|
||||||
|
if not private_key: # Create random key
|
||||||
|
private_key = bytes.hex(secp256k1._gen_private_key())
|
||||||
|
|
||||||
|
self.private_key = secp256k1.PrivateKey(bytes.fromhex(private_key))
|
||||||
|
self.private_key_hex = private_key
|
||||||
|
self.public_key = self.private_key.pubkey
|
||||||
|
self.public_key_hex = self.public_key.serialize().hex()[2:]
|
||||||
|
|
||||||
|
self.supported_methods = []
|
||||||
|
|
||||||
|
self.subscriptions_count = 0
|
||||||
|
|
||||||
|
self.request_listeners = {}
|
||||||
|
|
||||||
|
self.reconnect_task = None
|
||||||
|
|
||||||
|
self.sub = None
|
||||||
|
|
||||||
|
# websocket connection
|
||||||
|
self.ws = None
|
||||||
|
# if True the websocket is connected
|
||||||
|
self.connected = False
|
||||||
|
# if True the wallet is shutting down
|
||||||
|
self.shutdown = False
|
||||||
|
|
||||||
|
logger.info("NWC Service is ready. relay: "+str(self.relay)+" pubkey: " +
|
||||||
|
self.public_key_hex)
|
||||||
|
|
||||||
|
def getSupportedMethods(self):
|
||||||
|
return self.supported_methods
|
||||||
|
|
||||||
|
def addRequestListener(self, method: str, l: Callable[["NWCConnector", str, Dict], List[Tuple[Dict, Dict]]]):
|
||||||
|
if not method in self.supported_methods:
|
||||||
|
self.supported_methods.append(method)
|
||||||
|
self.request_listeners[method] = l
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""
|
||||||
|
Starts the NWC service connection.
|
||||||
|
"""
|
||||||
|
self.reconnect_task = asyncio.create_task(self._connect_to_relay())
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dumps(self, data: Union[Dict, list]) -> str:
|
||||||
|
"""
|
||||||
|
Converts a Python dictionary to a JSON string with compact encoding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (Dict): The dictionary to be converted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The compact JSON string.
|
||||||
|
"""
|
||||||
|
if isinstance(data, Dict):
|
||||||
|
data = {k: v for k, v in data.items() if v is not None}
|
||||||
|
return json.dumps(data, separators=(',', ':'), ensure_ascii=False)
|
||||||
|
|
||||||
|
def _is_shutting_down(self) -> bool:
|
||||||
|
"""
|
||||||
|
Returns True if the wallet is shutting down.
|
||||||
|
"""
|
||||||
|
return self.shutdown or not settings.lnbits_running
|
||||||
|
|
||||||
|
async def _send(self, data: Dict):
|
||||||
|
"""
|
||||||
|
Sends data to the NWC relay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (Dict): The data to be sent.
|
||||||
|
"""
|
||||||
|
if self._is_shutting_down():
|
||||||
|
logger.warning("Trying to send data while shutting down")
|
||||||
|
return
|
||||||
|
await self._wait_for_connection() # ensure the connection is established
|
||||||
|
tx = self._json_dumps(data)
|
||||||
|
await self.ws.send(tx)
|
||||||
|
|
||||||
|
def _get_new_subid(self) -> str:
|
||||||
|
"""
|
||||||
|
Generates a unique subscription id.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The generated 64 characters long subscription id (eg. lnbits0abc...)
|
||||||
|
"""
|
||||||
|
subid = "lnbitsnwcs"+str(self.subscriptions_count)
|
||||||
|
self.subscriptions_count += 1
|
||||||
|
maxLength = 64
|
||||||
|
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
n = maxLength - len(subid)
|
||||||
|
if n > 0:
|
||||||
|
for i in range(n):
|
||||||
|
subid += chars[random.randint(0, len(chars) - 1)]
|
||||||
|
return subid
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_connection(self):
|
||||||
|
"""
|
||||||
|
Waits until the wallet is connected to the relay.
|
||||||
|
"""
|
||||||
|
while not self.connected:
|
||||||
|
if self._is_shutting_down():
|
||||||
|
raise Exception("Connection is closing")
|
||||||
|
logger.debug("Waiting for connection...")
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _subscribe(self):
|
||||||
|
"""
|
||||||
|
[Re]Subscribe to receive nip 47 requests and responses from the relay
|
||||||
|
"""
|
||||||
|
self.sub = MainSubscription()
|
||||||
|
|
||||||
|
# Create requests subscription
|
||||||
|
req_filter = {
|
||||||
|
"kinds": [23194],
|
||||||
|
"#p": [self.public_key_hex],
|
||||||
|
# Since the last 3 hours (handles reboots)
|
||||||
|
"since": int(time.time()) - 3*60*60
|
||||||
|
}
|
||||||
|
self.sub.requests_sub_id = self._get_new_subid()
|
||||||
|
# Create responses subscription (needed to track previosly responded requests)
|
||||||
|
res_filter = {
|
||||||
|
"kinds": [23195],
|
||||||
|
"authors": [self.public_key_hex],
|
||||||
|
"since": int(time.time()) - 3*60*60
|
||||||
|
}
|
||||||
|
self.sub.responses_sub_id = self._get_new_subid()
|
||||||
|
# Subscribe
|
||||||
|
await self._send(["REQ", self.sub.requests_sub_id, req_filter])
|
||||||
|
await self._send(["REQ", self.sub.responses_sub_id, res_filter])
|
||||||
|
|
||||||
|
async def _on_connection(self,ws):
|
||||||
|
"""
|
||||||
|
On connection callback, announce the service provider methods and subscribe to nip67 events.
|
||||||
|
"""
|
||||||
|
# Send info event
|
||||||
|
event = {
|
||||||
|
"kind": 13194,
|
||||||
|
"content": " ".join(self.supported_methods),
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
"tags": [
|
||||||
|
["p", self.public_key_hex]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
self._sign_event(event)
|
||||||
|
await self._send(["EVENT", event])
|
||||||
|
# Resubscribe to nwc events
|
||||||
|
await self._subscribe()
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_request(self, event):
|
||||||
|
"""
|
||||||
|
Handle a nwc request
|
||||||
|
"""
|
||||||
|
nwc_pubkey = event["pubkey"]
|
||||||
|
content = event["content"]
|
||||||
|
# Decrypt the content
|
||||||
|
content = self._decrypt_content(content, nwc_pubkey)
|
||||||
|
# Deserialize content
|
||||||
|
content = json.loads(content)
|
||||||
|
# Handle request
|
||||||
|
method = content["method"]
|
||||||
|
l = self.request_listeners.get(method, None)
|
||||||
|
outs = []
|
||||||
|
if not l:
|
||||||
|
outs.append({
|
||||||
|
"error": {
|
||||||
|
"code": "NOT_IMPLEMENTED",
|
||||||
|
"message": "Method "+method+" is not implemented by this service provider"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
results = await l(self, nwc_pubkey, content)
|
||||||
|
for result in results:
|
||||||
|
r = result[0]
|
||||||
|
e = result[1]
|
||||||
|
t = result[2] if len(result) > 2 else None
|
||||||
|
out = {}
|
||||||
|
if r: out["result"] = r
|
||||||
|
if e: out["error"] = e
|
||||||
|
if t: out["tags"] = t
|
||||||
|
outs.append(out)
|
||||||
|
except Exception as e:
|
||||||
|
outs.append({
|
||||||
|
"code": "INTERNAL",
|
||||||
|
"message": str(e)
|
||||||
|
})
|
||||||
|
for out in outs:
|
||||||
|
# Finalize output
|
||||||
|
out["result_type"] = method
|
||||||
|
# Prepare response event
|
||||||
|
res = {
|
||||||
|
"kind": 23195,
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
"tags": out.get("tags", []),
|
||||||
|
"content": self._json_dumps(out),
|
||||||
|
}
|
||||||
|
# Reference request
|
||||||
|
res["tags"].append(["e", event["id"]])
|
||||||
|
# Reference user
|
||||||
|
res["tags"].append(["p", nwc_pubkey])
|
||||||
|
# Finalize response event
|
||||||
|
res["content"] = self._encrypt_content(res["content"], nwc_pubkey)
|
||||||
|
self._sign_event(res)
|
||||||
|
# Register response for this request, so we knows it is not stale
|
||||||
|
self.sub.registerResponse(event["id"])
|
||||||
|
# Send response event
|
||||||
|
|
||||||
|
await self._send(["EVENT", res])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _on_message(self, ws, message: str):
|
||||||
|
"""
|
||||||
|
Handle incoming messages from the relay.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
msg = json.loads(message)
|
||||||
|
if msg[0] == "EVENT": # Event message
|
||||||
|
sub_id = msg[1]
|
||||||
|
event = msg[2]
|
||||||
|
# Ensure the event is valid (do not trust relays)
|
||||||
|
if not self._verify_event(event):
|
||||||
|
raise Exception("Invalid event signature")
|
||||||
|
tags = event["tags"]
|
||||||
|
expiration = -1
|
||||||
|
for tag in tags:
|
||||||
|
if tag[0] == "expiration":
|
||||||
|
expiration = int(tag[1])
|
||||||
|
break
|
||||||
|
# Handle event expiration if the relay doesn't support nip 40
|
||||||
|
if expiration > 0 and expiration < int(time.time()):
|
||||||
|
logger.debug("Event expired")
|
||||||
|
return
|
||||||
|
if event["kind"] == 23194 and sub_id == self.sub.requests_sub_id:
|
||||||
|
# Ensure the request is for this service provider
|
||||||
|
valid_p = False
|
||||||
|
for tag in tags:
|
||||||
|
if tag[0] == "p" and tag[1] == self.public_key_hex:
|
||||||
|
valid_p = True
|
||||||
|
break
|
||||||
|
if not valid_p:
|
||||||
|
raise Exception("Unexpected request from another service")
|
||||||
|
# Track request
|
||||||
|
self.sub.events[event["id"]] = event
|
||||||
|
# if eose was received for both subscriptions, we handle the request in realtime
|
||||||
|
# if not, we do nothing since the request may be already handled or stale,
|
||||||
|
# all stale requests will be handled later when eose is received
|
||||||
|
if self.sub.requests_eose and self.sub.responses_eose:
|
||||||
|
await self._handle_request(event)
|
||||||
|
elif event["kind"] == 23195 and sub_id == self.sub.responses_sub_id:
|
||||||
|
# Ensure the response is from this service provider
|
||||||
|
if event["pubkey"] != self.public_key_hex:
|
||||||
|
raise Exception("Unexpected response from another service")
|
||||||
|
# Register as response for each e tag (request event id)
|
||||||
|
# Note: usually we expect only one "e" tag, but we are handling multiple "e" tags just in case
|
||||||
|
for tag in tags:
|
||||||
|
if tag[0] == "e":
|
||||||
|
self.sub.registerResponse(tag[1])
|
||||||
|
elif msg[0] == "EOSE":
|
||||||
|
sub_id = msg[1]
|
||||||
|
# Track EOSE
|
||||||
|
if sub_id == self.sub.requests_sub_id:
|
||||||
|
self.sub.requests_eose = True
|
||||||
|
elif sub_id == self.sub.responses_sub_id:
|
||||||
|
self.sub.responses_eose = True
|
||||||
|
# When both EOSE are receives, handle all the stale requests
|
||||||
|
# Note: All the requests that were received prior to the service connection
|
||||||
|
# and do not have a response yet, are considered stale, we will process them now
|
||||||
|
if self.sub.requests_eose and self.sub.responses_eose:
|
||||||
|
stales = self.sub.getStale()
|
||||||
|
for stale in stales:
|
||||||
|
await self._handle_request(stale)
|
||||||
|
elif msg[0] == "CLOSED":
|
||||||
|
# Subscription was closed remotely.
|
||||||
|
sub_id = msg[1]
|
||||||
|
info = msg[2] or "" if len(msg) > 2 else ""
|
||||||
|
# Resubscribe if one of the main subscriptions was closed
|
||||||
|
if sub_id == self.sub.requests_sub_id or sub_id == self.sub.responses_sub_id:
|
||||||
|
logger.warning("Subscription "+sub_id+" was closed remotely: "+info+" ... resubscribing...")
|
||||||
|
self._subscribe()
|
||||||
|
elif msg[0] == "NOTICE":
|
||||||
|
# A message from the relay, mostly useless, but we log it anyway
|
||||||
|
logger.info("Notice from relay "+self.relay+": "+str(msg[1]))
|
||||||
|
elif msg[0] == "OK":
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise Exception("Unknown message type")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error parsing event: "+str(e))
|
||||||
|
|
||||||
|
async def _connect_to_relay(self):
|
||||||
|
"""
|
||||||
|
Initiate websocket connection to the relay.
|
||||||
|
"""
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
logger.debug("Connecting to NWC relay "+self.relay)
|
||||||
|
while not self._is_shutting_down(): # Reconnect until the wallet is shutting down
|
||||||
|
logger.debug('Creating new connection...')
|
||||||
|
try:
|
||||||
|
async with websockets.connect(self.relay) as ws:
|
||||||
|
self.ws = ws
|
||||||
|
self.connected = True
|
||||||
|
await self._on_connection(ws)
|
||||||
|
while not self._is_shutting_down(): # receive messages until the wallet is shutting down
|
||||||
|
try:
|
||||||
|
reply = await ws.recv()
|
||||||
|
await self._on_message(ws, reply)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Error receiving message: " + str(e))
|
||||||
|
break
|
||||||
|
logger.debug("Connection to NWC relay closed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error connecting to NWC relay: "+str(e))
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
# the connection was closed, so we set the connected flag to False
|
||||||
|
# this will make the methods calling _wait_for_connection() to wait until the connection is re-established
|
||||||
|
self.connected = False
|
||||||
|
if not self._is_shutting_down():
|
||||||
|
# Wait some time before reconnecting
|
||||||
|
logger.debug("Reconnecting to NWC relay in 5 seconds...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
def _encrypt_content(self, content: str, pubkey_hex:str) -> str:
|
||||||
|
"""
|
||||||
|
Encrypts the content to be sent to the service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content (str): The content to be encrypted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The encrypted content.
|
||||||
|
"""
|
||||||
|
pubkey = secp256k1.PublicKey(
|
||||||
|
bytes.fromhex("02" + pubkey_hex), True)
|
||||||
|
|
||||||
|
shared = pubkey.tweak_mul(bytes.fromhex(
|
||||||
|
self.private_key_hex)).serialize()[1:]
|
||||||
|
# random iv (16B)
|
||||||
|
iv = Random.new().read(AES.block_size)
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
# padding
|
||||||
|
def pad(s): return s + (16 - len(s) % 16) * chr(16 - len(s) % 16)
|
||||||
|
content = pad(content).encode("utf-8")
|
||||||
|
# Encrypt
|
||||||
|
encryptedB64 = base64.b64encode(aes.encrypt(content)).decode("ascii")
|
||||||
|
ivB64 = base64.b64encode(iv).decode("ascii")
|
||||||
|
encryptedContent = encryptedB64 + "?iv=" + ivB64
|
||||||
|
return encryptedContent
|
||||||
|
|
||||||
|
def _decrypt_content(self, content: str , pubkey_hex:str) -> str:
|
||||||
|
"""
|
||||||
|
Decrypts the content coming from the service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content (str): The encrypted content.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The decrypted content.
|
||||||
|
"""
|
||||||
|
pubkey = secp256k1.PublicKey(
|
||||||
|
bytes.fromhex("02" + pubkey_hex), True)
|
||||||
|
|
||||||
|
shared = pubkey.tweak_mul(bytes.fromhex(
|
||||||
|
self.private_key_hex)).serialize()[1:]
|
||||||
|
# extract iv and content
|
||||||
|
(encryptedContentB64, ivB64) = content.split("?iv=")
|
||||||
|
encryptedContent = base64.b64decode(
|
||||||
|
encryptedContentB64.encode("ascii"))
|
||||||
|
iv = base64.b64decode(ivB64.encode("ascii"))
|
||||||
|
# Decrypt
|
||||||
|
aes = AES.new(shared, AES.MODE_CBC, iv)
|
||||||
|
decrypted = aes.decrypt(encryptedContent).decode("utf-8")
|
||||||
|
def unpad(s): return s[:-ord(s[len(s)-1:])]
|
||||||
|
return unpad(decrypted)
|
||||||
|
|
||||||
|
def _verify_event(self, event: Dict) -> bool:
|
||||||
|
"""
|
||||||
|
Signs the event (in place) with the service secret
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event (Dict): The event to be signed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: The input event with the signature added.
|
||||||
|
"""
|
||||||
|
signature_data = self._json_dumps([
|
||||||
|
0,
|
||||||
|
event["pubkey"],
|
||||||
|
event["created_at"],
|
||||||
|
event["kind"],
|
||||||
|
event["tags"],
|
||||||
|
event["content"]
|
||||||
|
])
|
||||||
|
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
|
||||||
|
if event_id != event["id"]: # Invalid event id
|
||||||
|
return False
|
||||||
|
pubkeyHex = event["pubkey"]
|
||||||
|
pubkey = secp256k1.PublicKey(bytes.fromhex("02" + pubkeyHex), True)
|
||||||
|
if not pubkey.schnorr_verify(bytes.fromhex(event_id), bytes.fromhex(event["sig"]), None, raw=True):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _sign_event(self, event: Dict) -> Dict:
|
||||||
|
"""
|
||||||
|
Signs the event (in place) with the service secret
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event (Dict): The event to be signed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: The input event with the signature added.
|
||||||
|
"""
|
||||||
|
signature_data = self._json_dumps([
|
||||||
|
0,
|
||||||
|
self.public_key_hex,
|
||||||
|
event["created_at"],
|
||||||
|
event["kind"],
|
||||||
|
event["tags"],
|
||||||
|
event["content"]
|
||||||
|
])
|
||||||
|
|
||||||
|
event_id = hashlib.sha256(signature_data.encode()).hexdigest()
|
||||||
|
event["id"] = event_id
|
||||||
|
event["pubkey"] = self.public_key_hex
|
||||||
|
|
||||||
|
signature = (self.private_key.schnorr_sign(
|
||||||
|
bytes.fromhex(event_id), None, raw=True)).hex()
|
||||||
|
event["sig"] = signature
|
||||||
|
return event
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
logger.debug("Closing NWC Service Provider connection")
|
||||||
|
self.shutdown = True # Mark for shutdown
|
||||||
|
# close tasks
|
||||||
|
try:
|
||||||
|
if self.reconnect_task:
|
||||||
|
self.reconnect_task.cancel()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error closing reconnection task: "+str(e))
|
||||||
|
# close the websocket
|
||||||
|
try:
|
||||||
|
if self.ws:
|
||||||
|
await self.ws.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error closing websocket connection: "+str(e))
|
||||||
20
README.md
Normal file
20
README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
`The README.md typically serves as a guide for using the extension.`
|
||||||
|
|
||||||
|
# NWCService - An [LNbits](https://github.com/lnbits/lnbits) Extension
|
||||||
|
|
||||||
|
## A Starter Template for Your Own Extension
|
||||||
|
|
||||||
|
Ready to start hacking? Once you've forked this extension, you can incorporate functions from other extensions as needed.
|
||||||
|
|
||||||
|
### How to Use This Template
|
||||||
|
> This guide assumes you're using this extension as a base for a new one, and have installed LNbits using https://github.com/lnbits/lnbits/blob/main/docs/guide/installation.md#option-1-recommended-poetry.
|
||||||
|
|
||||||
|
1. Install and enable the extension either through the official LNbits manifest or by adding https://raw.githubusercontent.com/lnbits/nwcservice/main/manifest.json to `"Server"/"Server"/"Extension Sources"`.  
|
||||||
|
|
||||||
|
2. `Ctrl c` shut down your LNbits installation.
|
||||||
|
3. Download the extension files from https://github.com/lnbits/nwcservice to a folder outside of `/lnbits`, and initialize the folder with `git`. Alternatively, create a repo, copy the nwcservice extension files into it, then `git clone` the extension to a location outside of `/lnbits`.
|
||||||
|
4. Remove the installed extension from `lnbits/lnbits/extensions`.
|
||||||
|
5. Create a symbolic link using `ln -s /home/ben/Projects/<name of your extension> /home/ben/Projects/lnbits/lnbits/extensions`.
|
||||||
|
6. Restart your LNbits installation. You can now modify your extension and `git push` changes to a repo.
|
||||||
|
7. When you're ready to share your manifest so others can install it, edit `/lnbits/nwcservice/manifest.json` to include the git credentials of your extension.
|
||||||
|
8. IMPORTANT: If you want your extension to be added to the official LNbits manifest, please follow the guidelines here: https://github.com/lnbits/lnbits-extensions#important
|
||||||
58
__init__.py
Normal file
58
__init__.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import asyncio
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from lnbits.db import Database
|
||||||
|
from lnbits.helpers import template_renderer
|
||||||
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
db = Database("ext_nwcprovider")
|
||||||
|
execution_queue = asyncio.Queue()
|
||||||
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
nwcprovider_ext: APIRouter = APIRouter(
|
||||||
|
prefix="/nwcprovider", tags=["NWC Service Provider"]
|
||||||
|
)
|
||||||
|
|
||||||
|
nwcprovider_static_files = [
|
||||||
|
{
|
||||||
|
"path": "/nwcprovider/static",
|
||||||
|
"name": "nwcprovider",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def nwcprovider_renderer():
|
||||||
|
return template_renderer(["nwcprovider/templates"])
|
||||||
|
|
||||||
|
|
||||||
|
async def enqueue(action):
|
||||||
|
future = asyncio.Future()
|
||||||
|
execution_queue.put_nowait({
|
||||||
|
"action": action,
|
||||||
|
"future": future
|
||||||
|
})
|
||||||
|
return await future
|
||||||
|
|
||||||
|
from .views import *
|
||||||
|
from .views_api import *
|
||||||
|
from .tasks import handle_nwc, handle_execution_queue
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def nwcprovider_stop():
|
||||||
|
for task in scheduled_tasks:
|
||||||
|
try:
|
||||||
|
task.cancel()
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
|
def nwcprovider_start():
|
||||||
|
task = create_permanent_unique_task("ext_nwcprovider", handle_nwc)
|
||||||
|
scheduled_tasks.append(task)
|
||||||
|
task = create_permanent_unique_task(
|
||||||
|
"ext_nwcprovider_execution_queue", handle_execution_queue)
|
||||||
|
scheduled_tasks.append(task)
|
||||||
|
|
||||||
27
config.json
Normal file
27
config.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "NWC Service Provider",
|
||||||
|
"short_description": "A NWC service provider for lnbits",
|
||||||
|
"tile": "/nwcprovider/static/image/nwcprovider.png",
|
||||||
|
"min_lnbits_version": "0.12.5",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "Riccardo Balbo",
|
||||||
|
"uri": "https://github.com/riccardobl",
|
||||||
|
"role": "Dev"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/static/image/3.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description_md": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/description.md",
|
||||||
|
"terms_and_conditions_md": "https://raw.githubusercontent.com/riccardobl/nwcprovider/main/toc.md",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
196
crud.py
Normal file
196
crud.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
from typing import Optional, Any, List
|
||||||
|
from . import db
|
||||||
|
from .models import NWCKey, NWCBudget, NWCLog, NWCNewBudget
|
||||||
|
import time
|
||||||
|
from . import enqueue
|
||||||
|
import json
|
||||||
|
|
||||||
|
async def create_nwc(
|
||||||
|
pubkey: str,
|
||||||
|
wallet_id:str,
|
||||||
|
description: str,
|
||||||
|
expires_at: int ,
|
||||||
|
permissions:List[str] ,
|
||||||
|
budgets: Optional[List[NWCNewBudget]] = None
|
||||||
|
):
|
||||||
|
# Check if the key already exists
|
||||||
|
if await get_nwc(pubkey, None, True):
|
||||||
|
raise Exception("Public key already used")
|
||||||
|
now=int(time.time())
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO nwcprovider.keys (pubkey, wallet, description, permissions, created_at, expires_at, last_used)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(pubkey, wallet_id, description, " ".join(permissions), now,int(expires_at) if expires_at else 0, now)
|
||||||
|
)
|
||||||
|
|
||||||
|
if budgets:
|
||||||
|
for budget in budgets:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO nwcprovider.budgets (pubkey, budget_msats, refresh_window, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(pubkey, budget.budget_msats, budget.refresh_window, budget.created_at)
|
||||||
|
)
|
||||||
|
return NWCKey(
|
||||||
|
pubkey=pubkey,
|
||||||
|
wallet=wallet_id,
|
||||||
|
description=description,
|
||||||
|
expires_at=expires_at,
|
||||||
|
permissions=" ".join(permissions),
|
||||||
|
created_at=now,
|
||||||
|
last_used=now
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_nwc(
|
||||||
|
pubkey: str,
|
||||||
|
wallet_id:str
|
||||||
|
):
|
||||||
|
nwc = await get_nwc(pubkey, wallet_id)
|
||||||
|
if not nwc:
|
||||||
|
raise Exception("Public key does not exist")
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM nwcprovider.keys WHERE pubkey = ? AND wallet = ?
|
||||||
|
""",
|
||||||
|
(pubkey, wallet_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def get_wallet_nwcs(
|
||||||
|
wallet_id: str,
|
||||||
|
include_expired: Optional[bool] = False
|
||||||
|
) -> List[NWCKey]:
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM nwcprovider.keys WHERE wallet = ? AND (expires_at = 0 OR expires_at > ?)", (wallet_id, int(time.time()) if not include_expired else -1 )
|
||||||
|
)
|
||||||
|
return [NWCKey(**row) for row in rows]
|
||||||
|
|
||||||
|
async def get_nwc(
|
||||||
|
pubkey: str,
|
||||||
|
wallet_id: Optional[str] = None,
|
||||||
|
include_expired: Optional[bool] = False,
|
||||||
|
refresh_last_used: Optional[bool] = False
|
||||||
|
) -> Optional[NWCKey]:
|
||||||
|
# expires_at = 0 means it never expires
|
||||||
|
if wallet_id:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM nwcprovider.keys WHERE pubkey = ? AND wallet = ? AND (expires_at = 0 OR expires_at > ?)", (pubkey, wallet_id, int(time.time()) if not include_expired else -1 )
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM nwcprovider.keys WHERE pubkey = ? AND (expires_at = 0 OR expires_at > ?)", (pubkey, int(time.time()) if not include_expired else -1 )
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if refresh_last_used:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE nwcprovider.keys SET last_used = ? WHERE pubkey = ?
|
||||||
|
""",
|
||||||
|
(int(time.time()), pubkey)
|
||||||
|
)
|
||||||
|
return NWCKey(**row)
|
||||||
|
|
||||||
|
async def get_budgets_nwc(pubkey, calculate_spent=False):
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM nwcprovider.budgets WHERE pubkey = ?", (pubkey)
|
||||||
|
)
|
||||||
|
budgets = [NWCBudget(**row) for row in rows]
|
||||||
|
if calculate_spent:
|
||||||
|
for budget in budgets:
|
||||||
|
last_cycle, next_cycle = budget.get_timestamp_range()
|
||||||
|
tot_spent_in_range_msats = await db.fetchone(
|
||||||
|
"""
|
||||||
|
SELECT SUM(amount_msats) FROM nwcprovider.spent WHERE pubkey = ? AND created_at >= ? AND created_at < ?
|
||||||
|
""",
|
||||||
|
(pubkey, last_cycle, next_cycle)
|
||||||
|
)
|
||||||
|
tot_spent_in_range_msats = tot_spent_in_range_msats[0] or 0
|
||||||
|
budget.used_budget_msats = tot_spent_in_range_msats
|
||||||
|
return budgets
|
||||||
|
|
||||||
|
|
||||||
|
async def log_nwc(
|
||||||
|
pubkey: str,
|
||||||
|
payload:Optional[Any] = None
|
||||||
|
):
|
||||||
|
if not payload: payload=""
|
||||||
|
payload = json.dumps(payload)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO nwcprovider.logs (pubkey, payload, created_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(pubkey, payload, int(time.time()))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def tracked_spend_nwc(
|
||||||
|
pubkey: str,
|
||||||
|
amount_msats: int,
|
||||||
|
action
|
||||||
|
):
|
||||||
|
async def r():
|
||||||
|
created_at = int(time.time())
|
||||||
|
budgets = await get_budgets_nwc(pubkey)
|
||||||
|
in_budget = True
|
||||||
|
for budget in budgets:
|
||||||
|
last_cycle, next_cycle = budget.get_timestamp_range()
|
||||||
|
tot_spent_in_range_msats = await db.fetchone(
|
||||||
|
"""
|
||||||
|
SELECT SUM(amount_msats) FROM nwcprovider.spent WHERE pubkey = ? AND created_at >= ? AND created_at < ?
|
||||||
|
""",
|
||||||
|
(pubkey, last_cycle, next_cycle)
|
||||||
|
)[0] or 0 # Ensure we get an int, default to 0 if None
|
||||||
|
if tot_spent_in_range_msats + amount_msats > budget.budget_msats:
|
||||||
|
in_budget = False
|
||||||
|
break
|
||||||
|
if not in_budget:
|
||||||
|
return False, None
|
||||||
|
out = await action()
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO nwcprovider.spent (pubkey, amount_msats, created_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(pubkey, amount_msats, created_at)
|
||||||
|
)
|
||||||
|
return True, out
|
||||||
|
return await enqueue(r)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def get_config_nwc(key:str):
|
||||||
|
row = await db.fetchone(
|
||||||
|
"SELECT * FROM nwcprovider.config WHERE key = ?", (key)
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return row["value"]
|
||||||
|
|
||||||
|
async def get_all_config_nwc():
|
||||||
|
rows = await db.fetchall(
|
||||||
|
"SELECT * FROM nwcprovider.config"
|
||||||
|
)
|
||||||
|
return {row["key"]:row["value"] for row in rows}
|
||||||
|
|
||||||
|
async def set_config_nwc(key:str, value:str):
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM nwcprovider.config
|
||||||
|
WHERE key = ?
|
||||||
|
""",
|
||||||
|
(key,)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO nwcprovider.config (key, value)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(key, value)
|
||||||
|
)
|
||||||
|
|
||||||
10
description.md
Normal file
10
description.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
NWCService can be used as a template for building new extensions, it includes a bunch of functions that can be edited/deleted as you need them.
|
||||||
|
|
||||||
|
This is a longform description that will be used in the advanced description when users click on the "more" button on the extension cards.
|
||||||
|
|
||||||
|
Adding some bullets is nice covering:
|
||||||
|
|
||||||
|
* Functionality
|
||||||
|
* Use cases
|
||||||
|
|
||||||
|
...and some other text about just how great this etension is.
|
||||||
9
manifest.json
Normal file
9
manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"repos": [
|
||||||
|
{
|
||||||
|
"id": "nwcprovider",
|
||||||
|
"organisation": "riccardobl",
|
||||||
|
"repository": "nwcprovider"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
112
migrations.py
Normal file
112
migrations.py
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import secp256k1
|
||||||
|
|
||||||
|
async def m001_initial(db):
|
||||||
|
"""
|
||||||
|
Initial tables
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE nwcprovider.keys (
|
||||||
|
pubkey TEXT PRIMARY KEY,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
permissions TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE nwcprovider.spent (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pubkey TEXT NOT NULL,
|
||||||
|
amount_msats INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(pubkey) REFERENCES keys(pubkey) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE nwcprovider.logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pubkey TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(pubkey) REFERENCES keys(pubkey) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE nwcprovider.budgets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pubkey TEXT NOT NULL,
|
||||||
|
budget_msats INTEGER NOT NULL,
|
||||||
|
refresh_window INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(pubkey) REFERENCES keys(pubkey) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def m002_config(db):
|
||||||
|
"""
|
||||||
|
Config table
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE nwcprovider.config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
async def m003_default_config(db):
|
||||||
|
"""
|
||||||
|
Default config
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO nwcprovider.config (key, value) VALUES ('relay', 'nostrclient');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
new_private_key = bytes.hex(secp256k1._gen_private_key())
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO nwcprovider.config (key, value) VALUES ('provider_key', ?);
|
||||||
|
""",
|
||||||
|
(new_private_key,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def m004_default_config2(db):
|
||||||
|
"""
|
||||||
|
Default config
|
||||||
|
"""
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO nwcprovider.config (key, value) VALUES ('relay_alias', ?);
|
||||||
|
""",
|
||||||
|
('',)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m005_key_last_used(db):
|
||||||
|
"""
|
||||||
|
Add last_used to keys
|
||||||
|
"""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE nwcprovider.keys ADD COLUMN last_used INTEGER;
|
||||||
|
"""
|
||||||
|
)
|
||||||
88
models.py
Normal file
88
models.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Data models for your extension
|
||||||
|
|
||||||
|
from sqlite3 import Row
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class NWCKey(BaseModel):
|
||||||
|
pubkey: str
|
||||||
|
wallet: str
|
||||||
|
description: str
|
||||||
|
expires_at: int
|
||||||
|
permissions: str
|
||||||
|
created_at: int
|
||||||
|
last_used: int
|
||||||
|
|
||||||
|
def getPermissions(cls) -> List[str]:
|
||||||
|
try:
|
||||||
|
return cls.permissions.split(" ")
|
||||||
|
except:
|
||||||
|
# TODO: log error
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Dict[str, Any]) -> "NWCKey":
|
||||||
|
return cls(**row)
|
||||||
|
|
||||||
|
class NWCBudget(BaseModel):
|
||||||
|
id: int
|
||||||
|
pubkey: str
|
||||||
|
budget_msats: int
|
||||||
|
refresh_window: int
|
||||||
|
created_at: int
|
||||||
|
used_budget_msats: int = 0
|
||||||
|
|
||||||
|
def get_timestamp_range(cls):
|
||||||
|
c = int(time.time())
|
||||||
|
if cls.refresh_window <= 0: # never refresh
|
||||||
|
# return a timestamp in the future
|
||||||
|
return c + 21000000
|
||||||
|
# calculate the next refresh timestamp
|
||||||
|
elapsed = c - cls.created_at
|
||||||
|
passed_cycles = elapsed // cls.refresh_window
|
||||||
|
last_cycle = cls.created_at + (passed_cycles * cls.refresh_window)
|
||||||
|
next_cycle = last_cycle + cls.refresh_window
|
||||||
|
return last_cycle, next_cycle
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "NWCBudget":
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class NWCLog(BaseModel):
|
||||||
|
id: int
|
||||||
|
pubkey: str
|
||||||
|
payload: str
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row: Row) -> "NWCLog":
|
||||||
|
return cls(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class NWCNewBudget(BaseModel):
|
||||||
|
budget_msats: int
|
||||||
|
refresh_window: int
|
||||||
|
created_at: int
|
||||||
|
|
||||||
|
class NWCRegistrationRequest(BaseModel):
|
||||||
|
permissions: List[str]
|
||||||
|
description: str
|
||||||
|
expires_at: int
|
||||||
|
budgets: List[NWCNewBudget]
|
||||||
|
|
||||||
|
|
||||||
|
class NWCGetResponse(BaseModel):
|
||||||
|
data: NWCKey
|
||||||
|
budgets: List[NWCBudget]
|
||||||
|
|
||||||
|
|
||||||
49
permission.py
Normal file
49
permission.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
|
||||||
|
|
||||||
|
nwc_permissions = {
|
||||||
|
"pay":{
|
||||||
|
"name":"Send payments",
|
||||||
|
"methods":[
|
||||||
|
"multi_pay_invoice",
|
||||||
|
"pay_invoice",
|
||||||
|
"pay_keysend",
|
||||||
|
"multi_pay_keysend",
|
||||||
|
],
|
||||||
|
"default": True
|
||||||
|
},
|
||||||
|
"invoice":{
|
||||||
|
"name":"Create invoices",
|
||||||
|
"methods":[
|
||||||
|
"make_invoice"
|
||||||
|
],
|
||||||
|
"default": True
|
||||||
|
},
|
||||||
|
"lookup":{
|
||||||
|
"name":"Lookup status of invoice",
|
||||||
|
"methods":[
|
||||||
|
"lookup_invoice"
|
||||||
|
],
|
||||||
|
"default": True
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"name": "Read transaction history",
|
||||||
|
"methods": [
|
||||||
|
"list_transactions"
|
||||||
|
],
|
||||||
|
"default": True
|
||||||
|
},
|
||||||
|
"balance": {
|
||||||
|
"name": "Read wallet balance",
|
||||||
|
"methods": [
|
||||||
|
"get_balance"
|
||||||
|
],
|
||||||
|
"default": True
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"name": "Read account info",
|
||||||
|
"methods": [
|
||||||
|
"get_info"
|
||||||
|
],
|
||||||
|
"default": True
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
static/image/1.png
Normal file
BIN
static/image/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
static/image/2.png
Normal file
BIN
static/image/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
static/image/3.png
Normal file
BIN
static/image/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
static/image/nwcprovider.png
Normal file
BIN
static/image/nwcprovider.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
2
static/js/noble-secp256k1.min.js
vendored
Normal file
2
static/js/noble-secp256k1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
376
tasks.py
Normal file
376
tasks.py
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from lnbits.core.models import Payment
|
||||||
|
from lnbits.core.services import create_invoice, pay_invoice, check_transaction_status, PaymentError
|
||||||
|
from bolt11 import decode as bolt11_decode
|
||||||
|
from lnbits.core.crud import get_wallet_payment, get_payments, get_wallet
|
||||||
|
from .crud import get_nwc, tracked_spend_nwc, log_nwc, get_config_nwc
|
||||||
|
from . import execution_queue
|
||||||
|
from .NWCServiceProvider import NWCServiceProvider
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
from math import ceil
|
||||||
|
import time
|
||||||
|
from lnbits.db import Filters
|
||||||
|
from .models import NWCKey
|
||||||
|
from typing import Optional
|
||||||
|
from .permission import nwc_permissions
|
||||||
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
async def _check(nwc: Optional[NWCKey], method: str, payload: Dict):
|
||||||
|
# check
|
||||||
|
if not nwc:
|
||||||
|
return {
|
||||||
|
"code": "UNAUTHORIZED",
|
||||||
|
"message": "This public key has no wallet connected."
|
||||||
|
}
|
||||||
|
# check permissions
|
||||||
|
allowed = False
|
||||||
|
permissions = nwc.getPermissions()
|
||||||
|
for p in permissions:
|
||||||
|
allowed_methods = nwc_permissions.get(p, {}).get("methods", [])
|
||||||
|
if method in allowed_methods:
|
||||||
|
allowed = True
|
||||||
|
break
|
||||||
|
if not allowed:
|
||||||
|
return {
|
||||||
|
"code": "RESTRICTED",
|
||||||
|
"message": "This public key is not allowed to do this operation."
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_invoice(wallet_id:str, pubkey:str, invoice:str, amount_msats:int, description:Optional[str]=None):
|
||||||
|
async def execute_payment():
|
||||||
|
return await pay_invoice(wallet_id=wallet_id, payment_request=invoice,
|
||||||
|
max_sat=int(ceil(amount_msats/1000)),
|
||||||
|
description=description or ""
|
||||||
|
)
|
||||||
|
payment_hash = None
|
||||||
|
try:
|
||||||
|
in_budget, payment_hash = await tracked_spend_nwc(pubkey, amount_msats, execute_payment)
|
||||||
|
if not in_budget:
|
||||||
|
error = {
|
||||||
|
"code":"QUOTA_EXCEEDED",
|
||||||
|
"message": "The wallet has exceeded its spending quota."
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"error": error,
|
||||||
|
"in_budget": False
|
||||||
|
}
|
||||||
|
except PaymentError as e:
|
||||||
|
status = e.status
|
||||||
|
message = e.message
|
||||||
|
if status == "failed":
|
||||||
|
error = {
|
||||||
|
"code": "PAYMENT_FAILED",
|
||||||
|
"message": message
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"error": error,
|
||||||
|
"in_budget": False
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
if not payment_hash:
|
||||||
|
raise Exception("Payment hash not found")
|
||||||
|
wait_for_preimage = True # currently required by nip 47 specs, might change in future
|
||||||
|
payment_status = None
|
||||||
|
while wait_for_preimage:
|
||||||
|
payment_status = await check_transaction_status(wallet_id, payment_hash)
|
||||||
|
if payment_status.success:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return {
|
||||||
|
"preimage": payment_status.preimage or "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"fee_msats": payment_status.fee_msat,
|
||||||
|
"paid": payment_status.paid,
|
||||||
|
"payment_hash": payment_hash,
|
||||||
|
"in_budget": in_budget
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _on_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
|
||||||
|
nwc = await get_nwc(pubkey, None, False, True)
|
||||||
|
error = await _check(nwc, "pay_invoice", payload)
|
||||||
|
if error:
|
||||||
|
return [(None,error, [])]
|
||||||
|
params = payload.get("params", {})
|
||||||
|
invoice = params.get("invoice", None)
|
||||||
|
# Ensures invoice is provided
|
||||||
|
if not invoice:
|
||||||
|
raise Exception("Missing invoice")
|
||||||
|
invoice_data = bolt11_decode(invoice)
|
||||||
|
amount_msats = invoice_data.amount_msat
|
||||||
|
res = await _process_invoice(nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description)
|
||||||
|
error = res.get("error")
|
||||||
|
if error:
|
||||||
|
return [(None, error, [])]
|
||||||
|
preimage = res.get("preimage")
|
||||||
|
out = {
|
||||||
|
"preimage": preimage,
|
||||||
|
}
|
||||||
|
await log_nwc(pubkey, payload)
|
||||||
|
return [(out,None, [])]
|
||||||
|
|
||||||
|
|
||||||
|
async def _on_multi_pay_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
|
||||||
|
nwc = await get_nwc(pubkey, None, False, True)
|
||||||
|
error = await _check(nwc, "multi_pay_invoice", payload)
|
||||||
|
if error:
|
||||||
|
return [(None, error, [])]
|
||||||
|
params = payload.get("params", {})
|
||||||
|
invoices = params.get("invoices", [])
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Ensures all invoices are provided
|
||||||
|
for i in invoices:
|
||||||
|
invoice = i.get("invoice",None)
|
||||||
|
if not invoice:
|
||||||
|
raise Exception("Missing invoice")
|
||||||
|
|
||||||
|
for i in invoices:
|
||||||
|
try:
|
||||||
|
id = i.get("id", None)
|
||||||
|
invoice = i.get("invoice", None)
|
||||||
|
invoice_data = bolt11_decode(invoice)
|
||||||
|
amount_msats = invoice_data.amount_msat
|
||||||
|
res = await _process_invoice(nwc.wallet, pubkey, invoice, amount_msats, invoice_data.description)
|
||||||
|
error = res.get("error")
|
||||||
|
if error:
|
||||||
|
results.append((None, error, []))
|
||||||
|
else:
|
||||||
|
r = (
|
||||||
|
{
|
||||||
|
"preimage": res.get("preimage"),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"d": id if id else res.get("payment_hash"),
|
||||||
|
})
|
||||||
|
results.append(r)
|
||||||
|
except Exception as e:
|
||||||
|
results.append((None, {
|
||||||
|
"code": "INTERNAL",
|
||||||
|
"message": str(e)
|
||||||
|
}))
|
||||||
|
await log_nwc(pubkey, payload)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
async def _on_make_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
|
||||||
|
nwc = await get_nwc(pubkey, None, False, True)
|
||||||
|
error = await _check(nwc, "make_invoice", payload)
|
||||||
|
if error:
|
||||||
|
return [(None, error, [])]
|
||||||
|
params = payload.get("params", {})
|
||||||
|
amount_msats = params.get("amount", None)
|
||||||
|
# Ensures amount is provided
|
||||||
|
if not amount_msats:
|
||||||
|
raise Exception("Missing amount")
|
||||||
|
description = params.get("description", "")
|
||||||
|
description_hash = params.get("description_hash", None)
|
||||||
|
expiry = params.get("expiry" , None)
|
||||||
|
payment_hash, payment_request = await create_invoice(
|
||||||
|
wallet_id=nwc.wallet,
|
||||||
|
amount=int(amount_msats/1000),
|
||||||
|
currency="sat",
|
||||||
|
memo=description,
|
||||||
|
description_hash=bytes.fromhex(description_hash) if description_hash else None,
|
||||||
|
unhashed_description=description.encode("utf-8"),
|
||||||
|
expiry=expiry)
|
||||||
|
payment_status = await check_transaction_status(wallet_id=nwc.wallet, payment_hash=payment_hash)
|
||||||
|
preimage = payment_status.preimage
|
||||||
|
res = {
|
||||||
|
"type": "incoming",
|
||||||
|
"invoice": payment_request,
|
||||||
|
"description": description,
|
||||||
|
"description_hash": description_hash,
|
||||||
|
"preimage": preimage,
|
||||||
|
"payment_hash": payment_hash,
|
||||||
|
"amount": amount_msats,
|
||||||
|
#"fees_paid":None,
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
if expiry:
|
||||||
|
res["expires_at"] = int(time.time()) + int(expiry)
|
||||||
|
await log_nwc(pubkey, payload)
|
||||||
|
return [(res, None, [])]
|
||||||
|
|
||||||
|
|
||||||
|
async def _on_lookup_invoice(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
|
||||||
|
nwc = await get_nwc(pubkey, None, False, True)
|
||||||
|
error = await _check(nwc, "lookup_invoice", payload)
|
||||||
|
if error:
|
||||||
|
return [(None, error, [])]
|
||||||
|
params = payload.get("params", {})
|
||||||
|
payment_hash = params.get("payment_hash", None)
|
||||||
|
invoice = params.get("invoice", None)
|
||||||
|
# Ensure payment_hash or invoice are provided
|
||||||
|
if not payment_hash and not invoice:
|
||||||
|
raise Exception("Missing payment_hash or invoice")
|
||||||
|
# Extract hash from invoice if not provided
|
||||||
|
if not payment_hash:
|
||||||
|
invoice_data = bolt11_decode(invoice)
|
||||||
|
payment_hash = invoice_data.payment_hash
|
||||||
|
# Get payment data
|
||||||
|
payment = await get_wallet_payment(nwc.wallet, payment_hash)
|
||||||
|
if not payment:
|
||||||
|
raise Exception("Payment not found")
|
||||||
|
invoice_data = bolt11_decode(payment.bolt11)
|
||||||
|
is_settled = not payment.pending
|
||||||
|
res = {
|
||||||
|
"type": "outgoing" if payment.is_out else "incoming",
|
||||||
|
"invoice": payment.bolt11,
|
||||||
|
"description": invoice_data.description if invoice_data.description else payment.memo,
|
||||||
|
"preimage": payment.preimage if is_settled or payment.is_in else None,
|
||||||
|
"payment_hash": payment.payment_hash,
|
||||||
|
"amount": abs(payment.msat),
|
||||||
|
"fees_paid": abs(payment.fee),
|
||||||
|
"created_at": payment.time,
|
||||||
|
"expires_at": payment.time+payment.expiry,
|
||||||
|
"settled_at": payment.time if is_settled else None,
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
if invoice_data.description_hash:
|
||||||
|
res["description_hash"] = invoice_data.description_hash
|
||||||
|
await log_nwc(pubkey, payload)
|
||||||
|
return [(res, None, [])]
|
||||||
|
|
||||||
|
|
||||||
|
async def _on_list_transactions(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
|
||||||
|
nwc = await get_nwc(pubkey, None, False, True)
|
||||||
|
error = await _check(nwc, "list_transactions", payload)
|
||||||
|
if error:
|
||||||
|
return [(None, error, [])]
|
||||||
|
tfrom = payload.get("from", 0)
|
||||||
|
tto = payload.get("to", int(time.time()))
|
||||||
|
limit = payload.get("limit", 10)
|
||||||
|
offset = payload.get("offset", 0)
|
||||||
|
unpaid = payload.get("unpaid", False)
|
||||||
|
type = payload.get("type", None)
|
||||||
|
values = []
|
||||||
|
filters = Filters()
|
||||||
|
filters.where("time <= ?")
|
||||||
|
values.append(tto)
|
||||||
|
filters.values(values)
|
||||||
|
history = await get_payments(
|
||||||
|
wallet_id=nwc.wallet,
|
||||||
|
complete=True,
|
||||||
|
pending = unpaid,
|
||||||
|
outgoing=not type or type == "outgoing",
|
||||||
|
incoming =not type or type=="incoming",
|
||||||
|
since=tfrom,
|
||||||
|
exclude_uncheckable=False,
|
||||||
|
filters=filters,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
transactions = []
|
||||||
|
for p in history:
|
||||||
|
p: Payment
|
||||||
|
invoice_data = bolt11_decode(p.bolt11)
|
||||||
|
is_settled = not p.pending
|
||||||
|
transactions.append({
|
||||||
|
"type": "outgoing" if p.is_out else "incoming",
|
||||||
|
"invoice": p.bolt11,
|
||||||
|
"description": invoice_data.description,
|
||||||
|
"description_hash": invoice_data.description_hash,
|
||||||
|
"preimage": p.preimage if is_settled or p.is_in else None,
|
||||||
|
"payment_hash": p.payment_hash,
|
||||||
|
"amount": abs(p.msat),
|
||||||
|
"fees_paid": p.fee,
|
||||||
|
"created_at": p.time,
|
||||||
|
"settled_at": p.time if is_settled else None,
|
||||||
|
"metadata": {}
|
||||||
|
})
|
||||||
|
await log_nwc(pubkey, payload)
|
||||||
|
return [({
|
||||||
|
"transactions": transactions
|
||||||
|
}, None, [])]
|
||||||
|
|
||||||
|
|
||||||
|
async def _on_get_balance(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
|
||||||
|
nwc = await get_nwc(pubkey, None, False, True)
|
||||||
|
error = await _check(nwc, "get_balance", payload)
|
||||||
|
if error:
|
||||||
|
return [(None, error, [])]
|
||||||
|
balance = 0
|
||||||
|
wallet = await get_wallet(nwc.wallet)
|
||||||
|
if not wallet:
|
||||||
|
raise Exception("Wallet not found")
|
||||||
|
balance = wallet.balance_msat
|
||||||
|
await log_nwc(pubkey, payload)
|
||||||
|
return [({
|
||||||
|
"balance": balance
|
||||||
|
}, None, [])]
|
||||||
|
|
||||||
|
|
||||||
|
async def _on_get_info(sp: NWCServiceProvider, pubkey: str, payload: Dict) -> List[Tuple[Dict, Dict]]:
|
||||||
|
nwc = await get_nwc(pubkey, None, False, True)
|
||||||
|
error = await _check(nwc, "get_info", payload)
|
||||||
|
if error:
|
||||||
|
return [(None, error, [])]
|
||||||
|
sp_methods = sp.getSupportedMethods()
|
||||||
|
permissions = nwc.getPermissions()
|
||||||
|
account_methods = []
|
||||||
|
for spm in sp_methods:
|
||||||
|
for p in permissions:
|
||||||
|
allowed_methods = nwc_permissions.get(p, {}).get("methods", [])
|
||||||
|
if spm in allowed_methods:
|
||||||
|
account_methods.append(spm)
|
||||||
|
break
|
||||||
|
await log_nwc(pubkey, payload)
|
||||||
|
return [({
|
||||||
|
"alias": settings.lnbits_site_title,
|
||||||
|
"color": "",
|
||||||
|
"network": "mainnet",
|
||||||
|
"block_height": 0,
|
||||||
|
"block_hash": "",
|
||||||
|
"methods": account_methods
|
||||||
|
}, None, [])]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_nwc():
|
||||||
|
priv_key = await get_config_nwc("provider_key")
|
||||||
|
relay = await get_config_nwc("relay")
|
||||||
|
nwcsp = NWCServiceProvider(priv_key, relay)
|
||||||
|
nwcsp.addRequestListener("pay_invoice", _on_pay_invoice)
|
||||||
|
nwcsp.addRequestListener("multi_pay_invoice", _on_multi_pay_invoice)
|
||||||
|
nwcsp.addRequestListener("make_invoice", _on_make_invoice)
|
||||||
|
nwcsp.addRequestListener("lookup_invoice", _on_lookup_invoice)
|
||||||
|
nwcsp.addRequestListener("list_transactions", _on_list_transactions)
|
||||||
|
nwcsp.addRequestListener("get_balance", _on_get_balance)
|
||||||
|
nwcsp.addRequestListener("get_info", _on_get_info)
|
||||||
|
# currently not supported by lnbits
|
||||||
|
# nwcsp.addRequestListener("pay_keysend", _on_pay_keysend)
|
||||||
|
# nwcsp.addRequestListener("multi_pay_keysend", _on_multi_pay_keysend)
|
||||||
|
###
|
||||||
|
await nwcsp.start()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await nwcsp.cleanup()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_execution_queue():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
task = await execution_queue.get()
|
||||||
|
action = task.get("action")
|
||||||
|
future = task.get("future")
|
||||||
|
try:
|
||||||
|
res = await action()
|
||||||
|
future.set_result(res)
|
||||||
|
except Exception as e:
|
||||||
|
future.set_exception(e)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(str(e))
|
||||||
103
templates/nwcprovider/admin.html
Normal file
103
templates/nwcprovider/admin.html
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||||
|
%} {% block page %}
|
||||||
|
<div class="row q-col-gutter-md" id="configTable">
|
||||||
|
<div class="col-12 q-gutter-y-md">
|
||||||
|
<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">NWC Service Provider - Config</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template>
|
||||||
|
<q-markup-table flat>
|
||||||
|
<tbody>
|
||||||
|
<q-tr v-for="entry in entries" :key="entry.key">
|
||||||
|
<q-td>
|
||||||
|
<span>
|
||||||
|
${entry.key}
|
||||||
|
</span>
|
||||||
|
</q-td>
|
||||||
|
<q-td>
|
||||||
|
<q-input v-model="entry.value" />
|
||||||
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</tbody>
|
||||||
|
</q-markup-table>
|
||||||
|
</template>
|
||||||
|
<q-btn @click="saveConfig" unelevated label="Save" color="primary" class="q-mt-md full-width" />
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script>
|
||||||
|
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
delimiters: ['${', '}'],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
columns: [
|
||||||
|
{ name: 'key', required: true, label: 'Key', align: 'left', field: row => row.key, sortable: true },
|
||||||
|
{ name: 'value', required: true, label: 'Value', align: 'left', field: row => row.value, sortable: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
fetchConfig() {
|
||||||
|
this.entries = [];
|
||||||
|
LNbits.api
|
||||||
|
.request('GET', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey)
|
||||||
|
.then((response) => {
|
||||||
|
const newEntries = [];
|
||||||
|
for (const [key, value] of Object.entries(response.data)) {
|
||||||
|
newEntries.push({
|
||||||
|
key: key,
|
||||||
|
value: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.entries = newEntries;
|
||||||
|
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
console.error("Error fetching config:", error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async saveConfig() {
|
||||||
|
const data = {};
|
||||||
|
for (const entry of this.entries) {
|
||||||
|
data[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await LNbits.api
|
||||||
|
.request('POST', '/nwcprovider/api/v1/config', this.g.user.wallets[0].adminkey, data)
|
||||||
|
|
||||||
|
|
||||||
|
this.$q.notify({
|
||||||
|
message: 'Config saved, please restart the server',
|
||||||
|
color: 'positive',
|
||||||
|
position: 'top',
|
||||||
|
timeout: 2000,
|
||||||
|
actions: [{ icon: 'close', color: 'white' }]
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving config:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created: function () {
|
||||||
|
this.fetchConfig()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
659
templates/nwcprovider/index.html
Normal file
659
templates/nwcprovider/index.html
Normal file
|
|
@ -0,0 +1,659 @@
|
||||||
|
<!--/////////////////////////////////////////////////-->
|
||||||
|
<!--//PAGE FOR THE EXTENSIONS BACKEND IN LNBITS//////-->
|
||||||
|
<!--/////////////////////////////////////////////////-->
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
<!-- <div class="q-gutter-x-md row items-center"> -->
|
||||||
|
<!-- <div class="col"> -->
|
||||||
|
<q-select v-model="selectedWallet" filled dense emit-value :options="g.user.walletOptions"
|
||||||
|
label="Wallet *"></q-select>
|
||||||
|
<!-- </div>
|
||||||
|
<div class="col">
|
||||||
|
<q-btn unelevated color="primary" icon="add" @click="formDialog.show = true">Connect app</q-btn>
|
||||||
|
</div> -->
|
||||||
|
<!-- </div> -->
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row items-center no-wrap q-mb-md">
|
||||||
|
<div class="col items-center">
|
||||||
|
<h5 class="text-subtitle1">Connected Apps</h5>
|
||||||
|
</div>
|
||||||
|
<q-btn @click="showConnectDialog()" aria-label="Connect" icon="add" color="primary" size="sm" round />
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<q-table dense flat :data="nwcEntries" row-key="id" :columns="nwcsTable.columns"
|
||||||
|
:pagination.sync="nwcsTable.pagination">
|
||||||
|
<nwcprovider 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-tr>
|
||||||
|
</nwcprovider>
|
||||||
|
|
||||||
|
<template v-slot:body="props">
|
||||||
|
<q-tr :props="props">
|
||||||
|
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||||
|
<div>${ col.value }</div>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<q-td>
|
||||||
|
<q-btn flat dense size="xs" @click="openConnectionInfoDialog(props.row)" icon="info" color="primary">
|
||||||
|
<q-tooltip> Info </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn flat dense size="xs" @click="deleteNWC(props.row.pubkey)" icon="cancel" color="pink">
|
||||||
|
<q-tooltip> Delete </q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
|
||||||
|
</q-tr>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</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">{{SITE_TITLE}} NWC Service provider extension</h6>
|
||||||
|
<p>
|
||||||
|
An extension that turns LNBits into a Nostr Wallet Connect service provider.
|
||||||
|
</p>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-list>
|
||||||
|
|
||||||
|
<q-item clickable @click="go('../docs#/NWC%20Service%20Provider')"
|
||||||
|
class="q-flex items-center justify-between">
|
||||||
|
<q-icon name="swap_vertical_circle" class="q-mr-md"></q-icon>
|
||||||
|
<q-item-section>Swagger API</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-separator></q-separator>
|
||||||
|
<q-item v-if="this.g.user.admin" clickable @click="go('/nwcprovider/admin')"
|
||||||
|
class="q-flex items-center justify-between">
|
||||||
|
<q-icon name="settings" class="q-mr-md"></q-icon>
|
||||||
|
<q-item-section>Settings</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<!-- <q-expansion-item v-if="this.g.user.admin" group="extras" icon="admin" label="Admin">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<p>
|
||||||
|
Manage wallets using NWC
|
||||||
|
</p>
|
||||||
|
<small>Created by
|
||||||
|
<a class="text-secondary" href="https://github.com/riccardobl" target="_blank">Riccardo Balbo</a>.</small>
|
||||||
|
<small>Repo
|
||||||
|
<a class="text-secondary" href="https://github.com/riccardobl/nwcprovider" target="_blank">NWC Service
|
||||||
|
Providers</a>.</small>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item> -->
|
||||||
|
</q-list>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--/////////////////////////////////////////////////-->
|
||||||
|
<!--//////////////FORM DIALOG////////////////////////-->
|
||||||
|
<!--/////////////////////////////////////////////////-->
|
||||||
|
|
||||||
|
|
||||||
|
<q-dialog v-model="connectionInfoDialog.show" @hide="closeConnectionInfoDialog">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<q-card-section class="q-ma-none">
|
||||||
|
<h3 class="q-ma-none q-mb-md text-center">Info</h3>
|
||||||
|
<q-list>
|
||||||
|
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>Description</q-item-section>
|
||||||
|
<q-item-section side>${connectionInfoDialog.data.description}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>Last used</q-item-section>
|
||||||
|
<q-item-section side>${connectionInfoDialog.data.last_used}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>Expires</q-item-section>
|
||||||
|
<q-item-section side>${connectionInfoDialog.data.expires_at}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>Created</q-item-section>
|
||||||
|
<q-item-section side>${connectionInfoDialog.data.created_at}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>Permissions</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
|
||||||
|
${connectionInfoDialog.data.permissions}
|
||||||
|
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>Limits
|
||||||
|
<q-markup-table class="q-mt-md">
|
||||||
|
<thead>
|
||||||
|
<q-tr>
|
||||||
|
<q-th>
|
||||||
|
Budget (sats)
|
||||||
|
<div style="font-size: smaller;">used/max</div>
|
||||||
|
|
||||||
|
|
||||||
|
</q-th>
|
||||||
|
<q-th>Refresh</q-th>
|
||||||
|
</q-tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<q-tr v-if="!connectionInfoDialog.data.budgets || connectionInfoDialog.data.budgets.length === 0">
|
||||||
|
<q-td colspan="2" class="text-center">No limits</q-td>
|
||||||
|
</q-tr>
|
||||||
|
<q-tr v-else v-for="budget in connectionInfoDialog.data.budgets">
|
||||||
|
<q-td class="text-center">${budget.used_budget_sats} / ${budget.budget_sats}</q-td>
|
||||||
|
<q-td class="text-center">${budget.refresh_window}</q-td>
|
||||||
|
</q-tr>
|
||||||
|
</tbody>
|
||||||
|
</q-markup-table>
|
||||||
|
|
||||||
|
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<q-card-actions class="q-mt-md">
|
||||||
|
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto full-width " @click="closePairingDialog">Close</q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
|
||||||
|
<q-dialog v-model="pairingQrDialog.show" @hide="closePairingQrDialog">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
|
||||||
|
<q-card-section class="q-ma-none">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||||
|
<lnbits-qrcode :value="pairingQrDialog.data.pairingUrl"></lnbits-qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section class="q-ma-none">
|
||||||
|
<p class=" q-ma-none text-center">Please scan this QR code with a supported app</p>
|
||||||
|
<p class=" q-ma-none text-center text-caption ">Connect only with app you trust!</p>
|
||||||
|
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn label="Close" flat color="grey" class="full-width
|
||||||
|
" @click="closePairingQrDialog"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="pairingDialog.show" @hide="closePairingDialog">
|
||||||
|
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
|
||||||
|
<q-card-section class="q-ma-none">
|
||||||
|
<h3 class="q-ma-none q-mb-md text-center">Pairing</h3>
|
||||||
|
<p class=" q-ma-none text-center">Complete the last step of the setup by pasting or scanning your connection's
|
||||||
|
pairing secret in the desired
|
||||||
|
app to
|
||||||
|
finalise the connection.</p>
|
||||||
|
<p class=" q-ma-none text-center text-caption ">Connect only with app you trust!</p>
|
||||||
|
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn label="Open in Supported App" color="primary" class="full-width" icon="launch"
|
||||||
|
@click="openPairingUrl()"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
<q-card-section class="q-ma-none">
|
||||||
|
<h5 class="q-ma-none text-center">Advanced</h5>
|
||||||
|
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn label="Copy Pairing URL" flat color="grey" class="full-width" icon="content_copy"
|
||||||
|
@click="copyPairingUrl()"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
<q-card-actions>
|
||||||
|
|
||||||
|
<q-btn label="Show QR Code" flat color="grey" class="full-width" icon="qr_code_scanner"
|
||||||
|
@click="showPairingQR()"></q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
|
||||||
|
<q-card-actions>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto full-width" @click="closePairingDialog">Close</q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<q-dialog v-model="connectDialog.show" @hide="closeConnectDialog">
|
||||||
|
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||||
|
<h3 class="q-ma-none q-mb-xl text-center">Add connection</h3>
|
||||||
|
|
||||||
|
<q-form @submit="confirmConnectDialog" class="q-gutter-md">
|
||||||
|
<q-input v-model="connectDialog.data.description" filled dense label="Description"></q-input>
|
||||||
|
<div class="row">
|
||||||
|
<q-input :disable="connectDialog.data.neverExpires" v-model="connectDialog.data.expires_at" class="col"
|
||||||
|
v-model="connectDialog.data.expires_at" filled dense label="Expires at" type="datetime-local"></q-input>
|
||||||
|
<q-checkbox v-model="connectDialog.data.neverExpires" class="col-auto"
|
||||||
|
v-model="connectDialog.data.neverExpires" label="Never"></q-checkbox>
|
||||||
|
</div>
|
||||||
|
<q-list dense bordered padding>
|
||||||
|
<q-item>
|
||||||
|
<q-item-label header>Authorize the app to</q-item-label>
|
||||||
|
</q-item>
|
||||||
|
<q-item v-for="permission in connectDialog.data.permissions">
|
||||||
|
<q-checkbox v-model="permission.value" :label="permission.name"></q-checkbox>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<q-list dense bordered padding>
|
||||||
|
<q-item>
|
||||||
|
<q-item-label header>Limit the spendable amount</q-item-label>
|
||||||
|
</q-item>
|
||||||
|
<q-item v-if="!connectDialog.data.budgets || connectDialog.data.budgets.length === 0">
|
||||||
|
<q-item-section class="full-width text-center">No limit</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
|
<q-item v-for="(budget,index) in connectDialog.data.budgets">
|
||||||
|
<q-item-section>
|
||||||
|
<q-input filled dense type="number" v-model="budget.budget_sats" label="Limit (sats)"></q-input>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-select filled dense emit-value :options="[
|
||||||
|
{label: 'Daily', value: 'Daily'},
|
||||||
|
{label: 'Weekly', value: 'Weekly'},
|
||||||
|
{label: 'Monthly', value: 'Monthly'},
|
||||||
|
{label: 'Yearly', value: 'Yearly'},
|
||||||
|
{label: 'Never', value: 'Never'}
|
||||||
|
]" label="Expires" v-model="budget.expiry"></q-select>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<q-btn flat icon="delete" @click="deleteBudget(index)"></q-btn>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
<q-item>
|
||||||
|
<q-btn flat icon="add" class="full-width" label="Add limit" @click="addBudget"></q-btn>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<q-card-actions class="q-gutter-sm">
|
||||||
|
<div class="col q-mr-md">
|
||||||
|
<q-btn unelevated color="primary" type="submit" class="full-width">Connect</q-btn>
|
||||||
|
</div>
|
||||||
|
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-form>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||||
|
<script type="module">
|
||||||
|
import * as NobleSecp256k1 from '/nwcprovider/static/js/noble-secp256k1.min.js';
|
||||||
|
window.NobleSecp256k1 = NobleSecp256k1;
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#vue',
|
||||||
|
mixins: [windowMixin],
|
||||||
|
delimiters: ['${', '}'],
|
||||||
|
data: function () {
|
||||||
|
return {
|
||||||
|
selectedWallet: null,
|
||||||
|
nodePermissions: [],
|
||||||
|
nwcEntries: [],
|
||||||
|
nwcsTable: {
|
||||||
|
columns: [
|
||||||
|
{ name: 'description', align: 'left', label: 'Description', field: 'description' },
|
||||||
|
{ name: 'status', align: 'left', label: 'Status', field: 'status' },
|
||||||
|
{ name: 'last_used', align: 'left', label: 'Last used', field: 'last_used' },
|
||||||
|
{ name: 'created_at', align: 'left', label: 'Created', field: 'created_at' },
|
||||||
|
{ name: 'expires_at', align: 'left', label: 'Expires', field: 'expires_at' }
|
||||||
|
],
|
||||||
|
pagination: {
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
connectDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
pairingDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
pairingUrl: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pairingQrDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {
|
||||||
|
pairingUrl: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
connectionInfoDialog: {
|
||||||
|
show: false,
|
||||||
|
data: {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
showConnectDialog() {
|
||||||
|
const wallet = this.getWallet();
|
||||||
|
if (!wallet) {
|
||||||
|
this.$q.notify({ color: 'red', message: 'Please select a wallet first' });
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this.connectDialog.show = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openConnectionInfoDialog(data) {
|
||||||
|
this.connectionInfoDialog.data = data;
|
||||||
|
this.connectionInfoDialog.show = true;
|
||||||
|
},
|
||||||
|
closeConnectionInfoDialog() {
|
||||||
|
this.connectionInfoDialog.show = false;
|
||||||
|
},
|
||||||
|
openPairingUrl() {
|
||||||
|
const url = this.pairingDialog.data.pairingUrl;
|
||||||
|
if (url) window.open(url, '_blank');
|
||||||
|
},
|
||||||
|
go(url) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
},
|
||||||
|
async copyPairingUrl() {
|
||||||
|
const url = this.pairingDialog.data.pairingUrl;
|
||||||
|
if (url) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
this.$q.notify({ color: 'primary', message: 'URL copied to clipboard!' });
|
||||||
|
} catch (err) {
|
||||||
|
this.$q.notify({ color: 'red', message: 'Failed to copy URL.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showPairingQR() {
|
||||||
|
this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl;
|
||||||
|
this.pairingQrDialog.show = true;
|
||||||
|
},
|
||||||
|
closePairingQrDialog() {
|
||||||
|
this.pairingQrDialog.show = false;
|
||||||
|
},
|
||||||
|
loadConnectDialogData() {
|
||||||
|
this.connectDialog.data = {
|
||||||
|
description: '',
|
||||||
|
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7,
|
||||||
|
neverExpires: true,
|
||||||
|
permissions: [],
|
||||||
|
budgets: []
|
||||||
|
};
|
||||||
|
for (const permission of this.nodePermissions) {
|
||||||
|
this.connectDialog.data.permissions.push({
|
||||||
|
key: permission.key,
|
||||||
|
name: permission.name,
|
||||||
|
value: permission.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteBudget(index) {
|
||||||
|
this.connectDialog.data.budgets.splice(index, 1)
|
||||||
|
},
|
||||||
|
addBudget() {
|
||||||
|
this.connectDialog.data.budgets.push({
|
||||||
|
budget_sats: 1000,
|
||||||
|
used_budget_sats: 0,
|
||||||
|
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
|
||||||
|
expiration: 'never'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
closeConnectDialog() {
|
||||||
|
this.connectDialog.show = false
|
||||||
|
this.loadConnectDialogData();
|
||||||
|
},
|
||||||
|
getWallet: function () {
|
||||||
|
let wallet = undefined;
|
||||||
|
for (let i = 0; i < this.g.user.wallets.length; i++) {
|
||||||
|
if (this.g.user.wallets[i].id == this.selectedWallet) {
|
||||||
|
wallet = this.g.user.wallets[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wallet;
|
||||||
|
},
|
||||||
|
async generateKeyPair() {
|
||||||
|
while (!window.NobleSecp256k1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1));
|
||||||
|
}
|
||||||
|
const privKeyBytes = window.NobleSecp256k1.utils.randomPrivateKey();
|
||||||
|
const pubKeyBytes = window.NobleSecp256k1.getPublicKey(privKeyBytes);
|
||||||
|
const out = {
|
||||||
|
privKeyBytes: privKeyBytes,
|
||||||
|
pubKeyBytes: pubKeyBytes,
|
||||||
|
privKey: window.NobleSecp256k1.etc.bytesToHex(privKeyBytes),
|
||||||
|
pubKey: window.NobleSecp256k1.etc.bytesToHex(pubKeyBytes.slice(1))
|
||||||
|
|
||||||
|
};
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
deleteNWC: async function (pubkey) {
|
||||||
|
this.$q.dialog({
|
||||||
|
title: 'Confirm Deletion',
|
||||||
|
message: 'Are you sure you want to delete this connection?',
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
const wallet = this.getWallet();
|
||||||
|
const response = await LNbits.api.request('DELETE', `/nwcprovider/api/v1/nwc/${pubkey}`, wallet.adminkey);
|
||||||
|
this.loadNwcs();
|
||||||
|
this.$q.notify({ type: 'positive', message: 'Deleted successfully' });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error);
|
||||||
|
}
|
||||||
|
}).onCancel(() => {
|
||||||
|
// User canceled the operation
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadNwcs: async function () {
|
||||||
|
|
||||||
|
const wallet = this.getWallet();
|
||||||
|
if (!wallet) {
|
||||||
|
this.nwcs = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await LNbits.api
|
||||||
|
.request(
|
||||||
|
'GET',
|
||||||
|
'/nwcprovider/api/v1/nwc?includeExpired=true&calculateSpendBudget=true',
|
||||||
|
wallet.adminkey
|
||||||
|
)
|
||||||
|
this.nwcs = response.data;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.nwcs = [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nwcprovider/api/v1/permissions',
|
||||||
|
wallet.adminkey
|
||||||
|
)
|
||||||
|
const permissions = [];
|
||||||
|
for (const [key, value] of Object.entries(response.data)) {
|
||||||
|
permissions.push({
|
||||||
|
key: key,
|
||||||
|
name: value.name,
|
||||||
|
value: value.default
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.nodePermissions = permissions;
|
||||||
|
} catch (error) {
|
||||||
|
Lnbits.utils.notifyApiError(error);
|
||||||
|
}
|
||||||
|
this.loadConnectDialogData();
|
||||||
|
const newTableEntries = [];
|
||||||
|
for (const nwc of this.nwcs) {
|
||||||
|
const t = Quasar.utils.date.formatDate(
|
||||||
|
new Date(nwc.data.created_at * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
);
|
||||||
|
const e = nwc.data.expires_at > 0 ? Quasar.utils.date.formatDate(
|
||||||
|
new Date(nwc.data.expires_at * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
) : "Never";
|
||||||
|
const l = Quasar.utils.date.formatDate(
|
||||||
|
new Date(nwc.data.last_used * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
);
|
||||||
|
const nwcTableEntry = {
|
||||||
|
description: nwc.data.description,
|
||||||
|
created_at: t,
|
||||||
|
expires_at: e,
|
||||||
|
last_used: l,
|
||||||
|
pubkey: nwc.data.pubkey,
|
||||||
|
permissions: nwc.data.permissions,
|
||||||
|
budgets: [],
|
||||||
|
status: "Active"
|
||||||
|
}
|
||||||
|
if(nwc.data.expires_at > 0 && nwc.data.expires_at < new Date().getTime() / 1000){
|
||||||
|
nwcTableEntry.status = "Expired";
|
||||||
|
}
|
||||||
|
for (const budget of nwc.budgets) {
|
||||||
|
const createdAt = Quasar.utils.date.formatDate(
|
||||||
|
new Date(budget.created_at * 1000),
|
||||||
|
'YYYY-MM-DD HH:mm'
|
||||||
|
);
|
||||||
|
let refreshWindow = budget.refresh_window;
|
||||||
|
if (refreshWindow <= 0) {
|
||||||
|
refreshWindow = 'Never';
|
||||||
|
} else if (refreshWindow == 60 * 60 * 24) {
|
||||||
|
refreshWindow = 'Daily';
|
||||||
|
} else if (refreshWindow == 60 * 60 * 24 * 7) {
|
||||||
|
refreshWindow = 'Weekly';
|
||||||
|
} else if (refreshWindow == 60 * 60 * 24 * 30) {
|
||||||
|
refreshWindow = 'Monthly';
|
||||||
|
} else if (refreshWindow == 60 * 60 * 24 * 365) {
|
||||||
|
refreshWindow = 'Yearly';
|
||||||
|
}
|
||||||
|
nwcTableEntry.budgets.push({
|
||||||
|
budget_sats: budget.budget_msats / 1000,
|
||||||
|
used_budget_sats: budget.used_budget_msats / 1000,
|
||||||
|
created_at: createdAt,
|
||||||
|
refresh_window: refreshWindow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
newTableEntries.push(nwcTableEntry);
|
||||||
|
}
|
||||||
|
this.nwcEntries = newTableEntries;
|
||||||
|
|
||||||
|
},
|
||||||
|
closePairingDialog() {
|
||||||
|
this.pairingDialog.show = false;
|
||||||
|
},
|
||||||
|
async showPairingDialog(secret) {
|
||||||
|
let response = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/nwcprovider/api/v1/pairing/{SECRET}'
|
||||||
|
);
|
||||||
|
response = response.data;
|
||||||
|
response = response.replace("{SECRET}", secret);
|
||||||
|
this.pairingDialog.data.pairingUrl = response;
|
||||||
|
this.pairingDialog.show = true;
|
||||||
|
},
|
||||||
|
async confirmConnectDialog() {
|
||||||
|
const keyPair = await this.generateKeyPair();
|
||||||
|
// timestamp
|
||||||
|
let expires_at = 0;
|
||||||
|
if (!this.connectDialog.data.neverExpires) {
|
||||||
|
expires_at = new Date(this.connectDialog.data.expires_at).getTime() / 1000;
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
permissions: [],
|
||||||
|
description: this.connectDialog.data.description,
|
||||||
|
expires_at: expires_at,
|
||||||
|
budgets: []
|
||||||
|
};
|
||||||
|
for (const permission of this.connectDialog.data.permissions) {
|
||||||
|
if (permission.value) data.permissions.push(permission.key);
|
||||||
|
}
|
||||||
|
for (const budget of this.connectDialog.data.budgets) {
|
||||||
|
const budget_msats = budget.budget_sats * 1000;
|
||||||
|
let refresh_window = 0
|
||||||
|
switch (budget.expiry) {
|
||||||
|
case 'Daily':
|
||||||
|
refresh_window = 60 * 60 * 24;
|
||||||
|
break;
|
||||||
|
case 'Weekly':
|
||||||
|
refresh_window = 60 * 60 * 24 * 7;
|
||||||
|
break;
|
||||||
|
case 'Monthly':
|
||||||
|
refresh_window = 60 * 60 * 24 * 30;
|
||||||
|
break;
|
||||||
|
case 'Yearly':
|
||||||
|
refresh_window = 60 * 60 * 24 * 365;
|
||||||
|
break;
|
||||||
|
case 'Never':
|
||||||
|
refresh_window = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
data.budgets.push({
|
||||||
|
budget_msats: budget_msats,
|
||||||
|
refresh_window: refresh_window,
|
||||||
|
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const wallet = this.getWallet();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await LNbits.api
|
||||||
|
.request('PUT', '/nwcprovider/api/v1/nwc/' + keyPair.pubKey, wallet.adminkey, data);
|
||||||
|
this.closeConnectDialog()
|
||||||
|
if (!response.data || !response.data.data || !response.data.data.pubkey) {
|
||||||
|
LNbits.utils.notifyApiError("Error creating nwc pairing");
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
this.showPairingDialog(keyPair.privKey);
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
this.loadNwcs();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created: function () {
|
||||||
|
this.loadNwcs()
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
selectedWallet(newValue, oldValue) {
|
||||||
|
this.loadNwcs();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
22
toc.md
Normal file
22
toc.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Terms and Conditions for LNbits Extension
|
||||||
|
|
||||||
|
## 1. Acceptance of Terms
|
||||||
|
By installing and using the LNbits extension ("Extension"), you agree to be bound by these terms and conditions ("Terms"). If you do not agree to these Terms, do not use the Extension.
|
||||||
|
|
||||||
|
## 2. License
|
||||||
|
The Extension is free and open-source software, released under [specify the FOSS license here, e.g., GPL-3.0, MIT, etc.]. You are permitted to use, copy, modify, and distribute the Extension under the terms of that license.
|
||||||
|
|
||||||
|
## 3. No Warranty
|
||||||
|
The Extension is provided "as is" and with all faults, and the developer expressly disclaims all warranties of any kind, whether express, implied, statutory, or otherwise, including but not limited to warranties of merchantability, fitness for a particular purpose, non-infringement, and any warranties arising out of course of dealing or usage of trade. No advice or information, whether oral or written, obtained from the developer or elsewhere will create any warranty not expressly stated in this Terms.
|
||||||
|
|
||||||
|
## 4. Limitation of Liability
|
||||||
|
In no event will the developer be liable to you or any third party for any direct, indirect, incidental, special, consequential, or punitive damages, including lost profit, lost revenue, loss of data, or other damages arising out of or in connection with your use of the Extension, even if the developer has been advised of the possibility of such damages. The foregoing limitation of liability shall apply to the fullest extent permitted by law in the applicable jurisdiction.
|
||||||
|
|
||||||
|
## 5. Modification of Terms
|
||||||
|
The developer reserves the right to modify these Terms at any time. You are advised to review these Terms periodically for any changes. Changes to these Terms are effective when they are posted on the appropriate location within or associated with the Extension.
|
||||||
|
|
||||||
|
## 6. General Provisions
|
||||||
|
If any provision of these Terms is held to be invalid or unenforceable, that provision will be enforced to the maximum extent permissible, and the other provisions of these Terms will remain in full force and effect. These Terms constitute the entire agreement between you and the developer regarding the use of the Extension.
|
||||||
|
|
||||||
|
## 7. Contact Information
|
||||||
|
If you have any questions about these Terms, please contact the developer at [developer's contact information].
|
||||||
26
views.py
Normal file
26
views.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from lnbits.core.models import User
|
||||||
|
from lnbits.decorators import check_user_exists
|
||||||
|
from lnbits.settings import settings
|
||||||
|
from lnbits.decorators import check_admin
|
||||||
|
|
||||||
|
from . import nwcprovider_ext,nwcprovider_renderer
|
||||||
|
|
||||||
|
|
||||||
|
@nwcprovider_ext.get("/", response_class=HTMLResponse)
|
||||||
|
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||||
|
return nwcprovider_renderer().TemplateResponse(
|
||||||
|
"nwcprovider/index.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
|
|
||||||
|
@nwcprovider_ext.get("/admin", response_class=HTMLResponse)
|
||||||
|
async def admin(request: Request, user: User = Depends(check_admin)):
|
||||||
|
return nwcprovider_renderer().TemplateResponse(
|
||||||
|
"nwcprovider/admin.html", {"request": request, "user": user.dict()}
|
||||||
|
)
|
||||||
167
views_api.py
Normal file
167
views_api.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
from http import HTTPStatus
|
||||||
|
import json
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
from .models import NWCKey, NWCBudget,NWCRegistrationRequest , NWCNewBudget,NWCGetResponse
|
||||||
|
from fastapi import Depends, Request
|
||||||
|
from loguru import logger
|
||||||
|
from lnbits.decorators import (
|
||||||
|
WalletTypeInfo,
|
||||||
|
get_key_type,
|
||||||
|
require_admin_key
|
||||||
|
)
|
||||||
|
from pydantic import BaseModel,Field
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from . import nwcprovider_ext
|
||||||
|
|
||||||
|
from .crud import get_nwc, get_wallet_nwcs, get_all_config_nwc, create_nwc, delete_nwc, get_budgets_nwc, get_config_nwc,set_config_nwc
|
||||||
|
|
||||||
|
|
||||||
|
from lnbits.decorators import check_admin
|
||||||
|
from fastapi import Depends
|
||||||
|
import secp256k1
|
||||||
|
from .permission import nwc_permissions
|
||||||
|
|
||||||
|
# Get supported permissions
|
||||||
|
@nwcprovider_ext.get("/api/v1/permissions", status_code=HTTPStatus.OK)
|
||||||
|
async def api_get_permissions(
|
||||||
|
req: Request,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
) -> Dict:
|
||||||
|
return nwc_permissions
|
||||||
|
|
||||||
|
|
||||||
|
## Get nwc keys associated with the wallet
|
||||||
|
@nwcprovider_ext.get("/api/v1/nwc", status_code=HTTPStatus.OK, response_model=List[NWCGetResponse])
|
||||||
|
async def api_get_nwcs(
|
||||||
|
req: Request,
|
||||||
|
includeExpired: Optional[bool] = False,
|
||||||
|
calculateSpendBudget: Optional[bool] = False,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
|
):
|
||||||
|
|
||||||
|
wallet_id = wallet.wallet.id
|
||||||
|
nwcs = await get_wallet_nwcs(wallet_id, includeExpired)
|
||||||
|
out = []
|
||||||
|
for nwc in nwcs:
|
||||||
|
budgets = await get_budgets_nwc(nwc.pubkey, calculateSpendBudget)
|
||||||
|
res = NWCGetResponse(
|
||||||
|
data=nwc,
|
||||||
|
budgets=budgets
|
||||||
|
)
|
||||||
|
out.append(res)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Get a nwc key
|
||||||
|
@nwcprovider_ext.get("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK, response_model=NWCGetResponse)
|
||||||
|
async def api_get_nwc(
|
||||||
|
req: Request,
|
||||||
|
pubkey: str,
|
||||||
|
includeExpired: Optional[bool] = False,
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
) -> NWCGetResponse:
|
||||||
|
wallet_id = wallet.wallet.id
|
||||||
|
nwc = await get_nwc(pubkey, wallet_id, includeExpired)
|
||||||
|
res = NWCGetResponse(
|
||||||
|
data=nwc,
|
||||||
|
budgets=await get_budgets_nwc(pubkey)
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
# Get pairing url for given secret
|
||||||
|
@nwcprovider_ext.get("/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str)
|
||||||
|
async def api_get_pairing_url(
|
||||||
|
req: Request,
|
||||||
|
secret: str
|
||||||
|
) -> str:
|
||||||
|
pprivkey = await get_config_nwc("provider_key")
|
||||||
|
relay = await get_config_nwc("relay")
|
||||||
|
relay_alias = await get_config_nwc("relay_alias")
|
||||||
|
if relay_alias:
|
||||||
|
relay = relay_alias
|
||||||
|
else:
|
||||||
|
if relay == "nostrclient":
|
||||||
|
scheme = req.url.scheme # http or https
|
||||||
|
netloc = req.url.netloc # hostname and port
|
||||||
|
if scheme=="http":
|
||||||
|
scheme = "ws"
|
||||||
|
else:
|
||||||
|
scheme = "wss"
|
||||||
|
netloc += "/nostrclient/api/v1/relay"
|
||||||
|
relay = f"{scheme}://{netloc}"
|
||||||
|
psk = secp256k1.PrivateKey(bytes.fromhex(pprivkey))
|
||||||
|
ppk = psk.pubkey
|
||||||
|
ppubkey = ppk.serialize().hex()[2:]
|
||||||
|
url = "nostr+walletconnect://"
|
||||||
|
url += ppubkey
|
||||||
|
url += "?relay="+relay
|
||||||
|
url += "&secret="+secret
|
||||||
|
#lud16=?
|
||||||
|
return url
|
||||||
|
|
||||||
|
## Register a new nwc key
|
||||||
|
@nwcprovider_ext.put("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.CREATED, response_model=NWCGetResponse)
|
||||||
|
async def api_register_nwc(
|
||||||
|
req: Request,
|
||||||
|
pubkey: str,
|
||||||
|
registration_data: NWCRegistrationRequest, # Use the Pydantic model here
|
||||||
|
wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
|
):
|
||||||
|
wallet_id = wallet.wallet.id
|
||||||
|
nwc = await create_nwc(pubkey, wallet_id, registration_data.description, registration_data.expires_at, registration_data.permissions, registration_data.budgets)
|
||||||
|
budgets = await get_budgets_nwc(pubkey)
|
||||||
|
res = NWCGetResponse(
|
||||||
|
data=nwc,
|
||||||
|
budgets=budgets
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# Delete a nwc key
|
||||||
|
@nwcprovider_ext.delete("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK)
|
||||||
|
async def api_delete_nwc(
|
||||||
|
req: Request,
|
||||||
|
pubkey: str,
|
||||||
|
wallet: WalletTypeInfo=Depends(require_admin_key)
|
||||||
|
):
|
||||||
|
wallet_id = wallet.wallet.id
|
||||||
|
await delete_nwc(pubkey, wallet_id)
|
||||||
|
return JSONResponse(content={"message": f"NWC key {pubkey} deleted successfully."})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Get config
|
||||||
|
@nwcprovider_ext.get("/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)])
|
||||||
|
async def api_get_all_config_nwc(
|
||||||
|
req: Request,
|
||||||
|
):
|
||||||
|
config = await get_all_config_nwc()
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
# Get config
|
||||||
|
@nwcprovider_ext.get("/api/v1/config/{key}", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)])
|
||||||
|
async def api_get_config_nwc(
|
||||||
|
req: Request,
|
||||||
|
key:str
|
||||||
|
):
|
||||||
|
config = await get_config_nwc(key)
|
||||||
|
out = {}
|
||||||
|
out[key] = config
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Set config
|
||||||
|
@nwcprovider_ext.post("/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)])
|
||||||
|
async def api_set_config_nwc(
|
||||||
|
req: Request
|
||||||
|
):
|
||||||
|
data = await req.json()
|
||||||
|
for key, value in data.items():
|
||||||
|
await set_config_nwc(key, value)
|
||||||
|
return await api_get_all_config_nwc(req)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue