From f06de0f27a029c321ca1f5ebfc7478666ba14ad6 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 16 Jul 2024 16:20:26 +0000 Subject: [PATCH] first --- .devcontainer/.env | 252 +++++++++++++++ .devcontainer/.env.example | 252 +++++++++++++++ .devcontainer/devcontainer.json | 34 ++ .devcontainer/start.sh | 7 + .github/xworkflows/main.yml | 53 +++ .github/xworkflows/release.yml | 53 +++ .gitignore | 1 + LICENSE | 54 ++++ README.md | 20 ++ __init__.py | 46 +++ config.json | 28 ++ crud.py | 100 ++++++ description.md | 10 + lnurl.py | 155 +++++++++ manifest.json | 9 + migrations.py | 36 +++ models.py | 34 ++ static/image/1.png | Bin 0 -> 13110 bytes static/image/2.png | Bin 0 -> 13110 bytes static/image/3.png | Bin 0 -> 13110 bytes static/image/nwcservice.png | Bin 0 -> 11921 bytes tasks.py | 57 ++++ templates/nwcservice/_api_docs.html | 3 + templates/nwcservice/_nwcservice.html | 13 + templates/nwcservice/index.html | 448 ++++++++++++++++++++++++++ templates/nwcservice/nwcservice.html | 63 ++++ toc.md | 22 ++ views.py | 91 ++++++ views_api.py | 168 ++++++++++ 29 files changed, 2009 insertions(+) create mode 100644 .devcontainer/.env create mode 100644 .devcontainer/.env.example create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/start.sh create mode 100644 .github/xworkflows/main.yml create mode 100644 .github/xworkflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 config.json create mode 100644 crud.py create mode 100644 description.md create mode 100644 lnurl.py create mode 100644 manifest.json create mode 100644 migrations.py create mode 100644 models.py create mode 100644 static/image/1.png create mode 100644 static/image/2.png create mode 100644 static/image/3.png create mode 100644 static/image/nwcservice.png create mode 100644 tasks.py create mode 100644 templates/nwcservice/_api_docs.html create mode 100644 templates/nwcservice/_nwcservice.html create mode 100644 templates/nwcservice/index.html create mode 100644 templates/nwcservice/nwcservice.html create mode 100644 toc.md create mode 100644 views.py create mode 100644 views_api.py diff --git a/.devcontainer/.env b/.devcontainer/.env new file mode 100644 index 0000000..b584ebb --- /dev/null +++ b/.devcontainer/.env @@ -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="LNbitsNWCFundsDev" +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=VoidWallet +# VoidWallet is just a fallback that works without any actual Lightning capabilities, +# just so you can see the UI before dealing with this file. + +# 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 ";;, ;;", 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 \ No newline at end of file diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example new file mode 100644 index 0000000..b584ebb --- /dev/null +++ b/.devcontainer/.env.example @@ -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="LNbitsNWCFundsDev" +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=VoidWallet +# VoidWallet is just a fallback that works without any actual Lightning capabilities, +# just so you can see the UI before dealing with this file. + +# 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 ";;, ;;", 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 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..43d69d3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,34 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "lnbitsnwcfundev", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.9-bullseye", + "features": { + "ghcr.io/devcontainers-contrib/features/poetry:2": { + } + + }, + "postCreateCommand": "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&&make bundle&&poetry install --no-interaction && mkdir -p lnbits/extensions/ && ln -s ${containerWorkspaceFolder} lnbits/extensions/nwcservice", + "mounts": [ + "source=${localWorkspaceFolder}/.devcontainer/start.sh,target=/start-lnbits.sh,type=bind" + ], + "postStartCommand": "/bin/bash /start-lnbits.sh", + + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [ + 5000 + ] + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "pip3 install --user -r requirements.txt", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.devcontainer/start.sh b/.devcontainer/start.sh new file mode 100644 index 0000000..13326d6 --- /dev/null +++ b/.devcontainer/start.sh @@ -0,0 +1,7 @@ +#!/bin/bash +if [ ! -f ".devcontainer/.env" ]; then + cp .devcontainer/.env.example .devcontainer/.env +fi +ln -s $PWD/.devcontainer/.env ../lnbits/.env +cd ../lnbits +poetry run lnbits diff --git a/.github/xworkflows/main.yml b/.github/xworkflows/main.yml new file mode 100644 index 0000000..f82e72e --- /dev/null +++ b/.github/xworkflows/main.yml @@ -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 diff --git a/.github/xworkflows/release.yml b/.github/xworkflows/release.yml new file mode 100644 index 0000000..f82e72e --- /dev/null +++ b/.github/xworkflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..93b0e4e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..49c7d37 --- /dev/null +++ b/README.md @@ -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"`. ![Extension Sources](https://i.imgur.com/MUGwAU3.png) ![image](https://github.com/lnbits/nwcservice/assets/33088785/4133123b-c747-4458-ba6c-5cc7c0f124d8) + +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/ /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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d629ca3 --- /dev/null +++ b/__init__.py @@ -0,0 +1,46 @@ +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 + +logger.debug("This logged message is from nwcservice/__init__.py, you can debug in your extension using 'import logger from loguru' and 'logger.debug()'.") + +db = Database("ext_nwcservice") + +nwcservice_ext: APIRouter = APIRouter( + prefix="/nwcservice", tags=["NWCService"] +) + +nwcservice_static_files = [ + { + "path": "/nwcservice/static", + "name": "nwcservice_static", + } +] + + +def nwcservice_renderer(): + return template_renderer(["nwcservice/templates"]) + + +from .lnurl import * +from .tasks import wait_for_paid_invoices +from .views import * +from .views_api import * + +scheduled_tasks: list[asyncio.Task] = [] + +def nwcservice_stop(): + for task in scheduled_tasks: + try: + task.cancel() + except Exception as ex: + logger.warning(ex) + +def nwcservice_start(): + task = create_permanent_unique_task("ext_nwcservice", wait_for_paid_invoices) + scheduled_tasks.append(task) \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..bb21831 --- /dev/null +++ b/config.json @@ -0,0 +1,28 @@ +{ + "name": "NWCService", + "short_description": "Minimal extension to build on", + "tile": "/nwcservice/static/image/nwcservice.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/nwcservice/main/static/image/1.png", + "link": "https://www.youtube.com/embed/SkkIwO_X4i4?si=9JJh1Fc6GfHDZK6b" + }, + { + "uri": "https://raw.githubusercontent.com/riccardobl/nwcservice/main/static/image/2.png" + }, + { + "uri": "https://raw.githubusercontent.com/riccardobl/nwcservice/main/static/image/3.png" + } + ], + "description_md": "https://raw.githubusercontent.com/riccardobl/nwcservice/main/description.md", + "terms_and_conditions_md": "https://raw.githubusercontent.com/riccardobl/nwcservice/main/toc.md", + "license": "MIT" +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..fd96746 --- /dev/null +++ b/crud.py @@ -0,0 +1,100 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash +from lnbits.lnurl import encode as lnurl_encode +from . import db +from .models import CreateNWCServiceData, NWCService +from loguru import logger +from fastapi import Request +from lnurl import encode as lnurl_encode +import shortuuid + + +async def create_nwcservice( + wallet_id: str, data: CreateNWCServiceData, req: Request +) -> NWCService: + nwcservice_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO nwcservice.maintable (id, wallet, name, lnurlpayamount, lnurlwithdrawamount) + VALUES (?, ?, ?, ?, ?) + """, + ( + nwcservice_id, + wallet_id, + data.name, + data.lnurlpayamount, + data.lnurlwithdrawamount, + ), + ) + nwcservice = await get_nwcservice(nwcservice_id, req) + assert nwcservice, "Newly created table couldn't be retrieved" + return nwcservice + + +async def get_nwcservice( + nwcservice_id: str, req: Optional[Request] = None +) -> Optional[NWCService]: + row = await db.fetchone( + "SELECT * FROM nwcservice.maintable WHERE id = ?", (nwcservice_id,) + ) + if not row: + return None + rowAmended = NWCService(**row) + if req: + rowAmended.lnurlpay = lnurl_encode( + req.url_for("nwcservice.api_lnurl_pay", nwcservice_id=row.id)._url + ) + rowAmended.lnurlwithdraw = lnurl_encode( + req.url_for( + "nwcservice.api_lnurl_withdraw", + nwcservice_id=row.id, + tickerhash=shortuuid.uuid(name=rowAmended.id + str(rowAmended.ticker)), + )._url + ) + return rowAmended + + +async def get_nwcservices( + wallet_ids: Union[str, List[str]], req: Optional[Request] = None +) -> List[NWCService]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM nwcservice.maintable WHERE wallet IN ({q})", (*wallet_ids,) + ) + tempRows = [NWCService(**row) for row in rows] + if req: + for row in tempRows: + row.lnurlpay = lnurl_encode( + req.url_for("nwcservice.api_lnurl_pay", nwcservice_id=row.id)._url + ) + row.lnurlwithdraw = lnurl_encode( + req.url_for( + "nwcservice.api_lnurl_withdraw", + nwcservice_id=row.id, + tickerhash=shortuuid.uuid(name=row.id + str(row.ticker)), + )._url + ) + return tempRows + + +async def update_nwcservice( + nwcservice_id: str, req: Optional[Request] = None, **kwargs +) -> NWCService: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE nwcservice.maintable SET {q} WHERE id = ?", + (*kwargs.values(), nwcservice_id), + ) + nwcservice = await get_nwcservice(nwcservice_id, req) + assert nwcservice, "Newly updated nwcservice couldn't be retrieved" + return nwcservice + + +async def delete_nwcservice(nwcservice_id: str) -> None: + await db.execute( + "DELETE FROM nwcservice.maintable WHERE id = ?", (nwcservice_id,) + ) diff --git a/description.md b/description.md new file mode 100644 index 0000000..b5c097e --- /dev/null +++ b/description.md @@ -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. diff --git a/lnurl.py b/lnurl.py new file mode 100644 index 0000000..46aef67 --- /dev/null +++ b/lnurl.py @@ -0,0 +1,155 @@ +# Maybe your extension needs some LNURL stuff. +# Here is a very simple example of how to do it. +# Feel free to delete this file if you don't need it. + +from http import HTTPStatus +from fastapi import Depends, Query, Request +from . import nwcservice_ext +from .crud import get_nwcservice +from lnbits.core.services import create_invoice, pay_invoice +from loguru import logger +from typing import Optional +from .crud import update_nwcservice +from .models import NWCService +import shortuuid + +################################################# +########### A very simple LNURLpay ############## +# https://github.com/lnurl/luds/blob/luds/06.md # +################################################# +################################################# + + +@nwcservice_ext.get( + "/api/v1/lnurl/pay/{nwcservice_id}", + status_code=HTTPStatus.OK, + name="nwcservice.api_lnurl_pay", +) +async def api_lnurl_pay( + request: Request, + nwcservice_id: str, +): + nwcservice = await get_nwcservice(nwcservice_id) + if not nwcservice: + return {"status": "ERROR", "reason": "No nwcservice found"} + return { + "callback": str( + request.url_for( + "nwcservice.api_lnurl_pay_callback", nwcservice_id=nwcservice_id + ) + ), + "maxSendable": nwcservice.lnurlpayamount * 1000, + "minSendable": nwcservice.lnurlpayamount * 1000, + "metadata": '[["text/plain", "' + nwcservice.name + '"]]', + "tag": "payRequest", + } + + +@nwcservice_ext.get( + "/api/v1/lnurl/paycb/{nwcservice_id}", + status_code=HTTPStatus.OK, + name="nwcservice.api_lnurl_pay_callback", +) +async def api_lnurl_pay_cb( + request: Request, + nwcservice_id: str, + amount: int = Query(...), +): + nwcservice = await get_nwcservice(nwcservice_id) + logger.debug(nwcservice) + if not nwcservice: + return {"status": "ERROR", "reason": "No nwcservice found"} + + payment_hash, payment_request = await create_invoice( + wallet_id=nwcservice.wallet, + amount=int(amount / 1000), + memo=nwcservice.name, + unhashed_description=f'[["text/plain", "{nwcservice.name}"]]'.encode(), + extra={ + "tag": "NWCService", + "nwcserviceId": nwcservice_id, + "extra": request.query_params.get("amount"), + }, + ) + return { + "pr": payment_request, + "routes": [], + "successAction": {"tag": "message", "message": f"Paid {nwcservice.name}"}, + } + + +################################################# +######## A very simple LNURLwithdraw ############ +# https://github.com/lnurl/luds/blob/luds/03.md # +################################################# +## withdraws are unique, removing 'tickerhash' ## +## here and crud.py will allow muliple pulls #### +################################################# + + +@nwcservice_ext.get( + "/api/v1/lnurl/withdraw/{nwcservice_id}/{tickerhash}", + status_code=HTTPStatus.OK, + name="nwcservice.api_lnurl_withdraw", +) +async def api_lnurl_withdraw( + request: Request, + nwcservice_id: str, + tickerhash: str, +): + nwcservice = await get_nwcservice(nwcservice_id) + if not nwcservice: + return {"status": "ERROR", "reason": "No nwcservice found"} + k1 = shortuuid.uuid(name=nwcservice.id + str(nwcservice.ticker)) + if k1 != tickerhash: + return {"status": "ERROR", "reason": "LNURLw already used"} + + return { + "tag": "withdrawRequest", + "callback": str( + request.url_for( + "nwcservice.api_lnurl_withdraw_callback", nwcservice_id=nwcservice_id + ) + ), + "k1": k1, + "defaultDescription": nwcservice.name, + "maxWithdrawable": nwcservice.lnurlwithdrawamount * 1000, + "minWithdrawable": nwcservice.lnurlwithdrawamount * 1000, + } + + +@nwcservice_ext.get( + "/api/v1/lnurl/withdrawcb/{nwcservice_id}", + status_code=HTTPStatus.OK, + name="nwcservice.api_lnurl_withdraw_callback", +) +async def api_lnurl_withdraw_cb( + request: Request, + nwcservice_id: str, + pr: Optional[str] = None, + k1: Optional[str] = None, +): + assert k1, "k1 is required" + assert pr, "pr is required" + nwcservice = await get_nwcservice(nwcservice_id) + if not nwcservice: + return {"status": "ERROR", "reason": "No nwcservice found"} + + k1Check = shortuuid.uuid(name=nwcservice.id + str(nwcservice.ticker)) + if k1Check != k1: + return {"status": "ERROR", "reason": "Wrong k1 check provided"} + + await update_nwcservice( + nwcservice_id=nwcservice_id, ticker=nwcservice.ticker + 1 + ) + await pay_invoice( + wallet_id=nwcservice.wallet, + payment_request=pr, + max_sat=int(nwcservice.lnurlwithdrawamount * 1000), + extra={ + "tag": "NWCService", + "nwcserviceId": nwcservice_id, + "lnurlwithdraw": True, + }, + ) + return {"status": "OK"} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..f839e3a --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "repos": [ + { + "id": "nwcservice", + "organisation": "riccardobl", + "repository": "nwcservice" + } + ] +} diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..264d068 --- /dev/null +++ b/migrations.py @@ -0,0 +1,36 @@ +# the migration file is where you build your database tables +# If you create a new release for your extension , remeember the migration file is like a blockchain, never edit only add! + + +async def m001_initial(db): + """ + Initial templates table. + """ + await db.execute( + """ + CREATE TABLE nwcservice.maintable ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + total INTEGER DEFAULT 0, + lnurlpayamount INTEGER DEFAULT 0, + lnurlwithdrawamount INTEGER DEFAULT 0, + lnurlwithdraw TEXT, + lnurlpay TEXT + ); + """ + ) + + +# Here we add another field to the database + + +async def m002_addtip_wallet(db): + """ + Add total to templates table + """ + await db.execute( + """ + ALTER TABLE nwcservice.maintable ADD ticker INTEGER DEFAULT 1; + """ + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..73628ba --- /dev/null +++ b/models.py @@ -0,0 +1,34 @@ +# Data models for your extension + +from sqlite3 import Row +from typing import Optional, List +from pydantic import BaseModel +from fastapi import Request + +from lnbits.lnurl import encode as lnurl_encode +from urllib.parse import urlparse + + +class CreateNWCServiceData(BaseModel): + wallet: Optional[str] + name: Optional[str] + total: Optional[int] + lnurlpayamount: Optional[int] + lnurlwithdrawamount: Optional[int] + ticker: Optional[int] + + +class NWCService(BaseModel): + id: str + wallet: Optional[str] + name: Optional[str] + total: Optional[int] + lnurlpayamount: Optional[int] + lnurlwithdrawamount: Optional[int] + lnurlpay: Optional[str] + lnurlwithdraw: Optional[str] + ticker: Optional[int] + + @classmethod + def from_row(cls, row: Row) -> "NWCService": + return cls(**dict(row)) diff --git a/static/image/1.png b/static/image/1.png new file mode 100644 index 0000000000000000000000000000000000000000..e0a39323dc5363065111296ca3597ac93cc059f4 GIT binary patch literal 13110 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 ScHlb*BqO0HULFQ0@6r` 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 None: + if payment.extra.get("tag") != "NWCService": + return + + nwcservice_id = payment.extra.get("nwcserviceId") + nwcservice = await get_nwcservice(nwcservice_id) + + # update something in the db + if payment.extra.get("lnurlwithdraw"): + total = nwcservice.total - payment.amount + else: + total = nwcservice.total + payment.amount + data_to_update = {"total": total} + + await update_nwcservice(nwcservice_id=nwcservice_id, **data_to_update) + + # here we could send some data to a websocket on wss:///api/v1/ws/ + # and then listen to it on the frontend, which we do with index.html connectWebocket() + + some_payment_data = { + "name": nwcservice.name, + "amount": payment.amount, + "fee": payment.fee, + "checking_id": payment.checking_id, + } + + await websocket_updater(nwcservice_id, str(some_payment_data)) diff --git a/templates/nwcservice/_api_docs.html b/templates/nwcservice/_api_docs.html new file mode 100644 index 0000000..27225f2 --- /dev/null +++ b/templates/nwcservice/_api_docs.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/templates/nwcservice/_nwcservice.html b/templates/nwcservice/_nwcservice.html new file mode 100644 index 0000000..73040a3 --- /dev/null +++ b/templates/nwcservice/_nwcservice.html @@ -0,0 +1,13 @@ + + + +

+ Some more info about my excellent extension. +

+ Created by + Ben Arc. + Repo + NWCService. +
+
+
\ No newline at end of file diff --git a/templates/nwcservice/index.html b/templates/nwcservice/index.html new file mode 100644 index 0000000..11ecb38 --- /dev/null +++ b/templates/nwcservice/index.html @@ -0,0 +1,448 @@ + + + + +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New NWCService + + + + + +
+
+
NWCService
+
+
+ Export to CSV +
+
+ + + + + ${ col.label } + + + + + + + +
+
+
+ +
+ + +
{{SITE_TITLE}} NWCService extension
+

Simple extension you can use as a base for your own extension.
Includes very simple LNURL-pay and + LNURL-withdraw example.

+
+ + + + {% include "nwcservice/_api_docs.html" %} + + {% include "nwcservice/_nwcservice.html" %} + + +
+
+ + + + + + + + + + + + +
+ Update NWCService + Create NWCService + Cancel +
+
+
+
+ + + + + + + + + + +
+
+ + + +
+
+ lnurlpay + +
+
+ lnurlwithdraw + +
+
+ + + + +
+
+
+ Close +
+
+
+ +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} \ No newline at end of file diff --git a/templates/nwcservice/nwcservice.html b/templates/nwcservice/nwcservice.html new file mode 100644 index 0000000..b1abab8 --- /dev/null +++ b/templates/nwcservice/nwcservice.html @@ -0,0 +1,63 @@ + + + + +{% extends "public.html" %} {% block page %} +
+
+ + + +
+ Copy LNURL +
+
+ +
+
+
+ + +
Public page
+

+ Most extensions have a public page that can be shared + (this page will still be accessible even if you have restricted + access to your LNbits install). +

+ In this example when a user pays the LNURLpay it triggers an event via a websocket waiting for the payment, which you can subscribe to somewhere using wss://{your-lnbits}/api/v1/ws/{the-id-of-this-record} + + + + + + +

+
+{% endblock %} {% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/toc.md b/toc.md new file mode 100644 index 0000000..fc97b10 --- /dev/null +++ b/toc.md @@ -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]. diff --git a/views.py b/views.py new file mode 100644 index 0000000..5098992 --- /dev/null +++ b/views.py @@ -0,0 +1,91 @@ +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 . import nwcservice_ext, nwcservice_renderer +from .crud import get_nwcservice + +myex = Jinja2Templates(directory="myex") + + +####################################### +##### ADD YOUR PAGE ENDPOINTS HERE #### +####################################### + + +# Backend admin page + + +@nwcservice_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return nwcservice_renderer().TemplateResponse( + "nwcservice/index.html", {"request": request, "user": user.dict()} + ) + + +# Frontend shareable page + + +@nwcservice_ext.get("/{nwcservice_id}") +async def nwcservice(request: Request, nwcservice_id): + nwcservice = await get_nwcservice(nwcservice_id, request) + if not nwcservice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="NWCService does not exist." + ) + return nwcservice_renderer().TemplateResponse( + "nwcservice/nwcservice.html", + { + "request": request, + "nwcservice_id": nwcservice_id, + "lnurlpay": nwcservice.lnurlpay, + "web_manifest": f"/nwcservice/manifest/{nwcservice_id}.webmanifest", + }, + ) + + +# Manifest for public page, customise or remove manifest completely + + +@nwcservice_ext.get("/manifest/{nwcservice_id}.webmanifest") +async def manifest(nwcservice_id: str): + nwcservice = await get_nwcservice(nwcservice_id) + if not nwcservice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="NWCService does not exist." + ) + + return { + "short_name": settings.lnbits_site_title, + "name": nwcservice.name + " - " + settings.lnbits_site_title, + "icons": [ + { + "src": settings.lnbits_custom_logo + if settings.lnbits_custom_logo + else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png", + "type": "image/png", + "sizes": "900x900", + } + ], + "start_url": "/nwcservice/" + nwcservice_id, + "background_color": "#1F2234", + "description": "Minimal extension to build on", + "display": "standalone", + "scope": "/nwcservice/" + nwcservice_id, + "theme_color": "#1F2234", + "shortcuts": [ + { + "name": nwcservice.name + " - " + settings.lnbits_site_title, + "short_name": nwcservice.name, + "description": nwcservice.name + " - " + settings.lnbits_site_title, + "url": "/nwcservice/" + nwcservice_id, + } + ], + } diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..8b124aa --- /dev/null +++ b/views_api.py @@ -0,0 +1,168 @@ +from http import HTTPStatus +import json + +import httpx +from fastapi import Depends, Query, Request +from lnurl import decode as decode_lnurl +from loguru import logger +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.core.models import Payment +from lnbits.core.services import create_invoice +from lnbits.core.views.api import api_payment +from lnbits.decorators import ( + WalletTypeInfo, + check_admin, + get_key_type, + require_admin_key, + require_invoice_key, +) + +from . import nwcservice_ext +from .crud import ( + create_nwcservice, + update_nwcservice, + delete_nwcservice, + get_nwcservice, + get_nwcservices, +) +from .models import CreateNWCServiceData + + +####################################### +##### ADD YOUR API ENDPOINTS HERE ##### +####################################### + +## Get all the records belonging to the user + + +@nwcservice_ext.get("/api/v1/myex", status_code=HTTPStatus.OK) +async def api_nwcservices( + req: Request, + all_wallets: bool = Query(False), + wallet: WalletTypeInfo = Depends(get_key_type), +): + wallet_ids = [wallet.wallet.id] + if all_wallets: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + return [ + nwcservice.dict() for nwcservice in await get_nwcservices(wallet_ids, req) + ] + + +## Get a single record + + +@nwcservice_ext.get("/api/v1/myex/{nwcservice_id}", status_code=HTTPStatus.OK) +async def api_nwcservice( + req: Request, nwcservice_id: str, WalletTypeInfo=Depends(get_key_type) +): + nwcservice = await get_nwcservice(nwcservice_id, req) + if not nwcservice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="NWCService does not exist." + ) + return nwcservice.dict() + + +## update a record + + +@nwcservice_ext.put("/api/v1/myex/{nwcservice_id}") +async def api_nwcservice_update( + req: Request, + data: CreateNWCServiceData, + nwcservice_id: str, + wallet: WalletTypeInfo = Depends(get_key_type), +): + if not nwcservice_id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="NWCService does not exist." + ) + nwcservice = await get_nwcservice(nwcservice_id, req) + assert nwcservice, "NWCService couldn't be retrieved" + + if wallet.wallet.id != nwcservice.wallet: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your NWCService." + ) + nwcservice = await update_nwcservice( + nwcservice_id=nwcservice_id, **data.dict(), req=req + ) + return nwcservice.dict() + + +## Create a new record + + +@nwcservice_ext.post("/api/v1/myex", status_code=HTTPStatus.CREATED) +async def api_nwcservice_create( + req: Request, + data: CreateNWCServiceData, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + nwcservice = await create_nwcservice( + wallet_id=wallet.wallet.id, data=data, req=req + ) + return nwcservice.dict() + + +## Delete a record + + +@nwcservice_ext.delete("/api/v1/myex/{nwcservice_id}") +async def api_nwcservice_delete( + nwcservice_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): + nwcservice = await get_nwcservice(nwcservice_id) + + if not nwcservice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="NWCService does not exist." + ) + + if nwcservice.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="Not your NWCService." + ) + + await delete_nwcservice(nwcservice_id) + return "", HTTPStatus.NO_CONTENT + + +# ANY OTHER ENDPOINTS YOU NEED + +## This endpoint creates a payment + + +@nwcservice_ext.post( + "/api/v1/myex/payment/{nwcservice_id}", status_code=HTTPStatus.CREATED +) +async def api_tpos_create_invoice( + nwcservice_id: str, amount: int = Query(..., ge=1), memo: str = "" +) -> dict: + nwcservice = await get_nwcservice(nwcservice_id) + + if not nwcservice: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="NWCService does not exist." + ) + + # we create a payment and add some tags, so tasks.py can grab the payment once its paid + + try: + payment_hash, payment_request = await create_invoice( + wallet_id=nwcservice.wallet, + amount=amount, + memo=f"{memo} to {nwcservice.name}" if memo else f"{nwcservice.name}", + extra={ + "tag": "nwcservice", + "amount": amount, + }, + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + return {"payment_hash": payment_hash, "payment_request": payment_request}