Compare commits
176
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f877dde2b0 | ||
|
|
594e28eab7 | ||
|
|
a67b93108b | ||
|
|
a198842f50 | ||
|
|
22b31a91c7 | ||
|
|
54881d777e | ||
|
|
b7e337b0ee | ||
|
|
b794f8302d | ||
|
|
805000cd06 | ||
|
|
bb94dc6526 | ||
|
|
d4e30356c7 | ||
|
|
4456dd6f59 | ||
|
|
eaec3480e6 | ||
|
|
8f1ae1646e | ||
|
|
d75c69cd10 | ||
|
|
2e12c0ba3c | ||
|
|
8947358c80 | ||
|
|
091f9b74f3 | ||
|
|
cf3c5d83fe | ||
|
|
2863653261 | ||
|
|
2552fd8fc9 | ||
|
|
a773584f6f | ||
|
|
85ec111ff0 | ||
|
|
52619ecc0f | ||
|
|
11e04e4314 | ||
|
|
32d75f7a60 | ||
|
|
435f5405c6 | ||
|
|
a46ca45de0 | ||
|
|
9cb58833ad | ||
|
|
c81e61e4fe | ||
|
|
bdbb58b2b0 | ||
|
|
be7d36214a | ||
|
|
f02ec67f35 | ||
|
|
d528d4e09e | ||
|
|
ec4b558638 | ||
|
|
0dc60d4795 | ||
|
|
3a56aaa3ad | ||
|
|
e1501e8ed6 | ||
|
|
1529ebb689 | ||
|
|
bcdc065cc0 | ||
|
|
cf0bd7ece8 | ||
|
|
c6bd060d19 | ||
|
|
8d135489ab | ||
|
|
69063190ab | ||
|
|
bc2207ba27 | ||
|
|
3cd15c40fc | ||
|
|
7a5159f293 | ||
|
|
fa8713de17 | ||
|
|
b1f30390b0 | ||
|
|
f2ea8a0c74 | ||
|
|
b5a07c7ae7 | ||
|
|
d5d85d16e6 | ||
|
|
2894944b1e | ||
|
|
31820e041c | ||
|
|
1d00060419 | ||
|
|
7f41d73a84 | ||
|
|
f638a1088e | ||
|
|
ea3418c21d | ||
|
|
f6bcff01f4 | ||
|
|
33fa540744 | ||
|
|
6f72f1fe6d | ||
|
|
76633fb71f | ||
|
|
9185342c72 | ||
|
|
fc6e49b821 | ||
|
|
35f8e3334c | ||
|
|
0b1f3c22e0 | ||
|
|
24c8201920 | ||
|
|
9a16dfa960 | ||
|
|
c5352c0309 | ||
|
|
95e8573ff8 | ||
|
|
4e68a0e7e6 | ||
|
|
16541d40c6 | ||
|
|
bb965e4119 | ||
|
|
5ead82f57c | ||
|
|
1eac618a25 | ||
|
|
e1744caec5 | ||
|
|
39c331a7b9 | ||
|
|
6e6ce093f0 | ||
|
|
1ce9936561 | ||
|
|
e408febf97 | ||
|
|
c3ded859df | ||
|
|
f5b8ed8fc6 | ||
|
|
9994e61615 | ||
|
|
e74cf33f90 | ||
|
|
b3c69ad49c | ||
|
|
90c640b659 | ||
|
|
28e6f40bea | ||
|
|
bbe8d05af4 | ||
|
|
2c92205703 | ||
|
|
74117ffc57 | ||
|
|
04222f1f01 | ||
|
|
e2f6c20e3b | ||
|
|
08b2ce5c05 | ||
|
|
5971eefe44 | ||
|
|
a11b967ffc | ||
|
|
f6e6bf73d3 | ||
|
|
f2619a4bbc | ||
|
|
88b8826bfd | ||
|
|
211ac0391b | ||
|
|
e0b8470d40 | ||
|
|
2dfae9ecc1 | ||
|
|
3ab29e6e50 | ||
|
|
f60cebb072 | ||
|
|
d8da88aefa | ||
|
|
49baa07141 | ||
|
|
098089af75 | ||
|
|
39cd96594e | ||
|
|
dbabf937c4 | ||
|
|
cd8a447ba0 | ||
|
|
ef0bdf8363 | ||
|
|
75047c5bc3 | ||
|
|
2b38207100 | ||
|
|
5513b34a66 | ||
|
|
6928f431a7 | ||
|
|
3a0762ff82 | ||
|
|
42c6620a07 | ||
|
|
f01028eac7 | ||
|
|
f452b9c00d | ||
|
|
1e2dd412c7 | ||
|
|
e6ad42dbe1 | ||
|
|
1faf0b152c | ||
|
|
f4fffc19aa | ||
|
|
6210305791 | ||
|
|
ce038193cf | ||
|
|
5ac91ee2cf | ||
|
|
4855e2cd3d | ||
|
|
487f082eb2 | ||
|
|
a8e1aa3154 | ||
|
|
afb2cc4339 | ||
|
|
6b94abcfb9 | ||
|
|
bd48bdc04d | ||
|
|
130beaf38a | ||
|
|
ab6229806c | ||
|
|
d62031b068 | ||
|
|
1bc5e144d3 | ||
|
|
ffa3c3f6a6 | ||
|
|
b8de30423f | ||
|
|
c965bca41d | ||
|
|
f238b3d1ef | ||
|
|
6b33f150c6 | ||
|
|
8b7028d728 | ||
|
|
23cfe0d417 | ||
|
|
a651f747ac | ||
|
|
dcd3f80974 | ||
|
|
6513908a8d | ||
|
|
753e9c5b15 | ||
|
|
dc3d96c6a8 | ||
|
|
ce28db76c9 | ||
|
|
197af922d0 | ||
|
|
4447a48724 | ||
|
|
d2650d6e2c | ||
|
|
b56877d470 | ||
|
|
bf3c44b3c4 | ||
|
|
c96b22664e | ||
|
|
d4f957a5c8 | ||
|
|
0d0e00e699 | ||
|
|
b4a6b45645 | ||
|
|
fadddc995a | ||
|
|
d09e624eb6 | ||
|
|
5d523519c3 | ||
|
|
68b0adfe66 | ||
|
|
660d56d400 | ||
|
|
2cecaa229b | ||
|
|
ac20e06dc3 | ||
|
|
79d5befe0c | ||
|
|
479760c5a5 | ||
|
|
3ce1e72e10 | ||
|
|
1e4f39954e | ||
|
|
01b19add79 | ||
|
|
96b80fb3fc | ||
|
|
3645e0ac38 | ||
|
|
0c7284be31 | ||
|
|
0de08dda0b | ||
|
|
a79ec8f4a4 | ||
|
|
24dc6e5415 | ||
|
|
bc27293315 |
+36
-26
@@ -1,46 +1,56 @@
|
||||
FLASK_APP=lnbits
|
||||
FLASK_ENV=development
|
||||
QUART_APP=lnbits.app:create_app()
|
||||
QUART_ENV=development
|
||||
QUART_DEBUG=true
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
LNBITS_SITE_TITLE=LNbits
|
||||
LNBITS_ALLOWED_USERS="all"
|
||||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
LNBITS_DATA_FOLDER="/your_custom_data_folder"
|
||||
LNBITS_DISABLED_EXTENSIONS="amilk,events"
|
||||
LNBITS_FORCE_HTTPS=1
|
||||
LNBITS_DATA_FOLDER="."
|
||||
LNBITS_DISABLED_EXTENSIONS="amilk"
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
LNBITS_SERVICE_FEE="0.0"
|
||||
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC), LndRestWallet, CLightningWallet, LnbitsWallet
|
||||
LNBITS_BACKEND_WALLET_CLASS=LntxbotWallet
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC),
|
||||
# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet
|
||||
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.
|
||||
|
||||
# Set one of these blocks depending on the wallet kind you chose above:
|
||||
# SparkWallet
|
||||
SPARK_URL=http://localhost:9737/rpc
|
||||
SPARK_TOKEN=myaccesstoken
|
||||
|
||||
# CLightningWallet
|
||||
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
|
||||
|
||||
LNBITS_ENDPOINT=127.0.0.1:5000
|
||||
LNBITS_INVOICE_MACAROON=LNBITS_INVOICE_MACAROON
|
||||
LNBITS_ADMIN_MACAROON=LNBITS_ADMIN_MACAROON
|
||||
# LnbitsWallet
|
||||
LNBITS_ENDPOINT=http://127.0.0.1:5000
|
||||
LNBITS_KEY=LNBITS_ADMIN_KEY
|
||||
|
||||
# LndWallet
|
||||
LND_GRPC_ENDPOINT=127.0.0.1
|
||||
LND_GRPC_PORT=11009
|
||||
LND_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_ADMIN_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon"
|
||||
LND_INVOICE_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/invoice.macaroon"
|
||||
LND_READ_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/read.macaroon"
|
||||
LND_GRPC_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_GRPC_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon"
|
||||
|
||||
LND_REST_ENDPOINT=https://localhost:8080/
|
||||
# LndRestWallet
|
||||
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
||||
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_REST_ADMIN_MACAROON="HEXSTRING"
|
||||
LND_REST_INVOICE_MACAROON="HEXSTRING"
|
||||
LND_REST_READ_MACAROON="HEXSTRING"
|
||||
LND_REST_MACAROON="HEXSTRING"
|
||||
|
||||
# LNPayWallet
|
||||
LNPAY_API_ENDPOINT=https://lnpay.co/v1/
|
||||
LNPAY_API_KEY=LNPAY_API_KEY
|
||||
LNPAY_ADMIN_KEY=LNPAY_ADMIN_KEY
|
||||
LNPAY_INVOICE_KEY=LNPAY_INVOICE_KEY
|
||||
LNPAY_READ_KEY=LNPAY_READ_KEY
|
||||
LNPAY_WALLET_KEY=LNPAY_ADMIN_KEY
|
||||
|
||||
# LntxbotWallet
|
||||
LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/
|
||||
LNTXBOT_ADMIN_KEY=LNTXBOT_ADMIN_KEY
|
||||
LNTXBOT_INVOICE_KEY=LNTXBOT_INVOICE_KEY
|
||||
LNTXBOT_KEY=LNTXBOT_ADMIN_KEY
|
||||
|
||||
# OpenNodeWallet
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_ADMIN_KEY=OPENNODE_ADMIN_KEY
|
||||
OPENNODE_INVOICE_KEY=OPENNODE_INVOICE_KEY
|
||||
OPENNODE_KEY=OPENNODE_ADMIN_KEY
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
**/static/vendor/** linguist-vendored
|
||||
@@ -1,54 +1,28 @@
|
||||
name: "CodeQL"
|
||||
name: codeql
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: '0 12 * * 5'
|
||||
|
||||
jobs:
|
||||
CodeQL-Build:
|
||||
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: javascript, python
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: formatting
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
black:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: sudo apt-get install python3-venv
|
||||
- run: python3 -m venv venv
|
||||
- run: ./venv/bin/pip install black
|
||||
- run: make checkblack
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: npm install
|
||||
- run: make checkprettier
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Run Linters
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
mypy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v1
|
||||
- name: Run MyPy python type checker
|
||||
uses: jpetrucciani/mypy-check@master
|
||||
with:
|
||||
path: 'lnbits'
|
||||
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v1
|
||||
- name: Check JS code formatting convention
|
||||
uses: creyD/prettier_action@v2.2
|
||||
with:
|
||||
dry: True
|
||||
prettier_options: --write lnbits/static/js/** lnbits/core/static/js/** lnbits/extensions/*/templates/**
|
||||
@@ -0,0 +1,12 @@
|
||||
name: mypy
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jpetrucciani/mypy-check@master
|
||||
with:
|
||||
path: lnbits
|
||||
@@ -1,15 +1,13 @@
|
||||
name: test suite
|
||||
name: tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.6, 3.7, 3.8]
|
||||
|
||||
python-version: [3.7, 3.8]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
|
||||
@@ -21,6 +21,7 @@ test-reports
|
||||
*.pyc
|
||||
*.env
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
|
||||
*.sqlite3
|
||||
@@ -29,3 +30,4 @@ venv
|
||||
__bundle__
|
||||
|
||||
node_modules
|
||||
lnbits/static/bundle.*
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.7
|
||||
FROM python:3.7-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/
|
||||
|
||||
@@ -1,2 +1,23 @@
|
||||
prettier:
|
||||
./node_modules/.bin/prettier --write lnbits/static/js/** lnbits/core/static/js/** lnbits/extensions/*/templates/**
|
||||
all: format check requirements.txt
|
||||
|
||||
format: prettier black
|
||||
|
||||
check: mypy checkprettier checkblack
|
||||
|
||||
prettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
||||
|
||||
black: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/black lnbits
|
||||
|
||||
mypy: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/mypy lnbits
|
||||
|
||||
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
||||
|
||||
checkblack: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/black --check lnbits
|
||||
|
||||
requirements.txt: Pipfile.lock
|
||||
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
|
||||
|
||||
@@ -9,21 +9,24 @@ python_version = "3.7"
|
||||
[packages]
|
||||
bitstring = "*"
|
||||
cerberus = "*"
|
||||
ecdsa = "*"
|
||||
environs = "*"
|
||||
lnurl = "*"
|
||||
flask = "*"
|
||||
flask-assets = "*"
|
||||
flask-compress = "*"
|
||||
flask-cors = "*"
|
||||
flask-talisman = "*"
|
||||
gevent = "==20.5.0"
|
||||
gunicorn = "*"
|
||||
pyscss = "*"
|
||||
requests = "*"
|
||||
shortuuid = "*"
|
||||
quart = "*"
|
||||
quart-cors = "*"
|
||||
quart-compress = "*"
|
||||
secure = "*"
|
||||
typing-extensions = "*"
|
||||
httpx = "*"
|
||||
quart-trio = "*"
|
||||
trio = "*"
|
||||
hypercorn = {extras = ["trio"], version = "*"}
|
||||
|
||||
[dev-packages]
|
||||
black = "==19.10b0"
|
||||
flake8 = "*"
|
||||
flake8-mypy = "*"
|
||||
black = "==20.8b1"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
mypy = "==0.761"
|
||||
pytest-trio = "*"
|
||||
|
||||
Generated
+377
-295
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "76587c919e4a0d49f3a4ff297353ec8b8c92521effdb5eaf985cb32213ebc5c5"
|
||||
"sha256": "193ef930ac0906127fd292b2fc7ba5b4d786227b6795d182dad9b57448fca75e"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -16,6 +16,29 @@
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"aiofiles": {
|
||||
"hashes": [
|
||||
"sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb",
|
||||
"sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af"
|
||||
],
|
||||
"version": "==0.5.0"
|
||||
},
|
||||
"async-generator": {
|
||||
"hashes": [
|
||||
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
|
||||
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.10"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
|
||||
"sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.2.0"
|
||||
},
|
||||
"bech32": {
|
||||
"hashes": [
|
||||
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
|
||||
@@ -31,43 +54,43 @@
|
||||
"index": "pypi",
|
||||
"version": "==3.1.7"
|
||||
},
|
||||
"blinker": {
|
||||
"hashes": [
|
||||
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
|
||||
],
|
||||
"version": "==1.4"
|
||||
},
|
||||
"brotli": {
|
||||
"hashes": [
|
||||
"sha256:0538dc1744fd17c314d2adc409ea7d1b779783b89fd95bcfb0c2acc93a6ea5a7",
|
||||
"sha256:0970a47f471782912d7705160b2b0a9306e68e6fadf9cffcaeb42d8f0951e26c",
|
||||
"sha256:113f51658e6fe548dce4b3749f6ef6c24de4184ba9c10a909cbee4261c2a5da0",
|
||||
"sha256:1e1aa9c4d1558889f42749c8baf846007953bfd32c8209230cf1cd1f5ef33495",
|
||||
"sha256:2f2f4f78f29ac4a45d15b3d9fc3fd9705e0ad313a44b129f6e1d0c6916bad0e2",
|
||||
"sha256:3269f6de1dd150fd0cce1c158b61ff5ac06d627fd3ae9c6ea03aed26fbbff7ea",
|
||||
"sha256:3f4a1f6240916c7984c7f2542786710f622992508dafee0b1714e6d340fb9ffd",
|
||||
"sha256:50dd9ad2a2bb12da4e9002a438672d182f98e546e99952de80280a1e1729664f",
|
||||
"sha256:5519a4b01b1a4f965083cbfa2ef2b9774c5a5f352341c47b50776ad109423d72",
|
||||
"sha256:5eb27722d320370315971c427eb8aa7cc0791f2a458840d357ac653bd0ad3a14",
|
||||
"sha256:5f06b4d5b6f58e5b5c220c2f23cad034dc5efa51b01fde2351ced1605bd980e2",
|
||||
"sha256:71ceee286ea7ec613f1c36f1c6181864a6ca24ebb55e371276f33d6af8742834",
|
||||
"sha256:72848d25a5f9e736db4af4512e0c3feecc094d57d241f8f1ae959115a2c39756",
|
||||
"sha256:743001bca75f4a6b4454be3510feca46f9d61a0c782a9bc2bc684bdb245e279e",
|
||||
"sha256:7ac98c71a15648fd11bc1f32608b6110e396121280790082e32b9a3109048bc6",
|
||||
"sha256:9d1c2dd27a1083fefd05b1b2f8df4a6bc2aaa6c21dd82cd41c8ae5e7c23a87f8",
|
||||
"sha256:a13ce9b419fe9f277c63f700efb0e444331509d1881b5610d2ba7e9080606967",
|
||||
"sha256:a19ef0952b9d2803df88dff07f45a6c92d5676afb9b8d69cf32232d684036d11",
|
||||
"sha256:ad766ca8b8c1419b71a22756b45264f45725c86133dc80a7cbe30b6b78c75620",
|
||||
"sha256:ad7963f261988ee0883816b6b9f206f11461c9b3cb5cfbca0c9ab5adc406d395",
|
||||
"sha256:af0451e23016631a2f52925a10d738ac4a0f794ac315c30380b22efc0c90cbc6",
|
||||
"sha256:c16201060c5a3f8742e3deae759014251ac92f382f82bc2a41dc079ff18c3f24",
|
||||
"sha256:c43b202f65891861a9a336984a103de25de235f756de69e32db893156f767013",
|
||||
"sha256:c675c6cce4295cb1a692f3de7416aacace7314e064b94bc86e93aceefce7fd3e",
|
||||
"sha256:d17cec0b992b1434f5f9df9986563605a4d1b1acd5574c87fc2ac014bcbd3316",
|
||||
"sha256:dc91f6129953861a73d9a65c52a8dd682b561a9ebaf65283541645cab6489917",
|
||||
"sha256:e2f4cbd1760d2bf2f30e396c2301999aab0191aec031a6a8a04950b2f575a536",
|
||||
"sha256:f192e6d3556714105c10486bbd6d045e38a0c04d9da3cef21e0a8dfd8e162df4",
|
||||
"sha256:f775b07026af2b1b0b5a8b05e41571cdcf3a315a67df265d60af301656a5425b",
|
||||
"sha256:f969ec7f56ba9636679e69ca07fba548312ccaca37412ee823c7f413541ad7e0",
|
||||
"sha256:f9dc52cd70907aafb99a773b66b156f2f995c7a0d284397c487c8b71ddbef2f9",
|
||||
"sha256:f9ee88bb52352588ceb811d045b5c9bb1dc38927bc150fd156244f60ff3f59f1",
|
||||
"sha256:fc7212e36ebeb81aebf7949c92897b622490d7c0e333a479c0395591e7994600"
|
||||
"sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8",
|
||||
"sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c",
|
||||
"sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70",
|
||||
"sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f",
|
||||
"sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429",
|
||||
"sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126",
|
||||
"sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4",
|
||||
"sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438",
|
||||
"sha256:503fa6af7da9f4b5780bb7e4cbe0c639b010f12be85d02c99452825dd0feef3f",
|
||||
"sha256:5913a1177fc36e30fcf6dc868ce23b0453952c78c04c266d3149b3d39e1410d6",
|
||||
"sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26",
|
||||
"sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14",
|
||||
"sha256:68715970f16b6e92c574c30747c95cf8cf62804569647386ff032195dc89a430",
|
||||
"sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296",
|
||||
"sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12",
|
||||
"sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452",
|
||||
"sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea",
|
||||
"sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a",
|
||||
"sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5",
|
||||
"sha256:9d12cf2851759b8de8ca5fde36a59c08210a97ffca0eb94c532ce7b17c6a3d1d",
|
||||
"sha256:afde17ae04d90fbe53afb628f7f2d4ca022797aa093e809de5c3cf276f61bbfa",
|
||||
"sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb",
|
||||
"sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b",
|
||||
"sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4",
|
||||
"sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7",
|
||||
"sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1",
|
||||
"sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"
|
||||
],
|
||||
"version": "==1.0.7"
|
||||
"version": "==1.0.9"
|
||||
},
|
||||
"cerberus": {
|
||||
"hashes": [
|
||||
@@ -83,13 +106,6 @@
|
||||
],
|
||||
"version": "==2020.6.20"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
@@ -98,110 +114,85 @@
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==7.1.2"
|
||||
},
|
||||
"flask": {
|
||||
"ecdsa": {
|
||||
"hashes": [
|
||||
"sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060",
|
||||
"sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"
|
||||
"sha256:494c6a853e9ed2e9be33d160b41d47afc50a6629b993d2b9c5ad7bb226add892",
|
||||
"sha256:ca359c971594dceebf334f3d623dae43163ab161c7d09f28cae70a86df26eb7a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.1.2"
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"flask-assets": {
|
||||
"environs": {
|
||||
"hashes": [
|
||||
"sha256:1dfdea35e40744d46aada72831f7613d67bf38e8b20ccaaa9e91fdc37aa3b8c2",
|
||||
"sha256:2845bd3b479be9db8556801e7ebc2746ce2d9edb4e7b64a1c786ecbfc1e5867b"
|
||||
"sha256:a98005aab7613b6fe7a1af7192a5163f72a52d3348d3918e6c7a2a32e4012779",
|
||||
"sha256:bf3fd6bc54fcfd7f512ddcb80a7781f0ced2b0c83dd123d619e9468ecdaaf537"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.0"
|
||||
"version": "==8.0.0"
|
||||
},
|
||||
"flask-compress": {
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:f367b2b46003dd62be34f7fb1379938032656dca56377a9bc90e7188e4289a7c"
|
||||
"sha256:3c6c61d69c6f13d41f1b80ab0322f1872702a3ba26e12aa864c928f6a43fbaab",
|
||||
"sha256:ab6c335e1b6ef34b205d5ca3e228c9299cc7218b049819ec84a388c2525e5d87"
|
||||
],
|
||||
"version": "==0.11.0"
|
||||
},
|
||||
"h2": {
|
||||
"hashes": [
|
||||
"sha256:ac9e293a1990b339d5d71b19c5fe630e3dd4d768c620d1730d355485323f1b25",
|
||||
"sha256:bb7ac7099dd67a857ed52c815a6192b6b1f5ba6b516237fc24a085341340593d"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.1'",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"hpack": {
|
||||
"hashes": [
|
||||
"sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c",
|
||||
"sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.1'",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"httpcore": {
|
||||
"hashes": [
|
||||
"sha256:18c4afcbfe884b635e59739105aed1692e132bc5d31597109f3c1c97e4ec1cac",
|
||||
"sha256:2526a38f31ac5967d38b7f593b5d8c4bd3fa82c21400402f866ba3312946acbf"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.12.0"
|
||||
},
|
||||
"httpx": {
|
||||
"hashes": [
|
||||
"sha256:126424c279c842738805974687e0518a94c7ae8d140cd65b9c4f77ac46ffa537",
|
||||
"sha256:9cffb8ba31fac6536f2c8cde30df859013f59e4bcc5b8d43901cb3654a8e0a5b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.5.0"
|
||||
"version": "==0.16.1"
|
||||
},
|
||||
"flask-cors": {
|
||||
"hypercorn": {
|
||||
"extras": [
|
||||
"trio"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:72170423eb4612f0847318afff8c247b38bd516b7737adfc10d1c2cdbb382d16",
|
||||
"sha256:f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a"
|
||||
"sha256:81c69dd84a87b8e8b3ebf06ef5dd92836a8238f0ac65ded3d86befb8ba9acfeb",
|
||||
"sha256:e3f317d6d64d15ce589f49e4f5057947259fa35332d169e62cb060e9997189e4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.8"
|
||||
"version": "==0.11.1"
|
||||
},
|
||||
"flask-talisman": {
|
||||
"hyperframe": {
|
||||
"hashes": [
|
||||
"sha256:468131464a249274ed226efc21b372518f442487e58918ccab8357eaa638fd1f",
|
||||
"sha256:eaa754f4b771dfbe473843391d69643b79e3a38c865790011ac5e4179c68e3ec"
|
||||
"sha256:742d2a4bc3152a340a49d59f32e33ec420aa8e7054c1444ef5c7efff255842f1",
|
||||
"sha256:a51026b1591cac726fc3d0b7994fbc7dc5efab861ef38503face2930fd7b2d34"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.7.0"
|
||||
},
|
||||
"gevent": {
|
||||
"hashes": [
|
||||
"sha256:00b03601b8dd1ee2aa07811cb60a4befe36173b15d91c6e207e37f8d77dd6fac",
|
||||
"sha256:0acc15ba2ac2a555529ad82d5a28fc85dbb6b2ff947657d67bebfd352e2b5c14",
|
||||
"sha256:15eae3cd450dac7dae7f4ac59e01db1378965c9ef565c39c5ae78c5a888f9ac9",
|
||||
"sha256:1dc7f1f6bc1f67d625e4272b01e717eba0b4fa024d2ff7934c8d320674d6f7fa",
|
||||
"sha256:1dd95433be45e1115053878366e3f5332ae99c39cb345be23851327c062b9f4a",
|
||||
"sha256:28b7d83b4327ceb79668eca2049bf4b9ce66d5ace18a88335e3035b573f889fd",
|
||||
"sha256:31dc5d4ab8172cc00c4ff17cb18edee633babd961f64bf54214244d769bc3a74",
|
||||
"sha256:38db524ea88d81d596b2cbb6948fced26654a15fec40ea4529224e239a6f45e8",
|
||||
"sha256:3ff477b6d275396123faf8ce2d5b82f96d85ba264e0b9d4b56a2bac49d1b9adc",
|
||||
"sha256:4d2729dd4bf9c4d0f29482f53cdf9fc90a498aebb5cd7ae8b45d35657437d2ac",
|
||||
"sha256:52e5cd607749ed3b8aa0272cacf2c11deec61fca4c3bec57a9fea8c49316627d",
|
||||
"sha256:5c604179cebcc57f10505d8db177b92a715907815a464b066e7eba322d1c33ac",
|
||||
"sha256:88c76df4967c5229f853aa67ad1b394d9e4f985b0359c9bc9879416bba3e7c68",
|
||||
"sha256:929c33df8e9bcbe31906024fcd21580bd018196dbd3249eb5b2f19d63e11092d",
|
||||
"sha256:92edc18a357473e01a4e4a82c073ed3c99ceca6e3ce93c23668dd4a2401f07dc",
|
||||
"sha256:937d36730f2b0dee3387712074b1f15b802e2e074a3d7c6dcaf70521236d607c",
|
||||
"sha256:9b4e940fc6071afebb86ba5f48dbb5f1fc3cb96ebeb8cf145eb5b499e9c6ee33",
|
||||
"sha256:a7805934e8ce81610b61f806572c3d504cedd698cc8c9460d78d2893ba598c4a",
|
||||
"sha256:d07a2afe4215731eb57d5b257a2e7e7e170d8a7ae1f02f6d0682cd3403debea9",
|
||||
"sha256:e01d5373528e4ebdde66dc47a608d225fa3c4408ccd828d26c49b7ff75d82bd9",
|
||||
"sha256:efd9546468502a30ddd4699c3124ccb9d3099130f9b5ae1e2a54ad5b46e86120",
|
||||
"sha256:fcb64f3a28420d1b872b7ef41b12e8a1a4dcadfc8eff3c09993ab0cdf52584a1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.5.0"
|
||||
},
|
||||
"greenlet": {
|
||||
"hashes": [
|
||||
"sha256:1000038ba0ea9032948e2156a9c15f5686f36945e8f9906e6b8db49f358e7b52",
|
||||
"sha256:133ba06bad4e5f2f8bf6a0ac434e0fd686df749a86b3478903b92ec3a9c0c90b",
|
||||
"sha256:1429dc183b36ec972055e13250d96e174491559433eb3061691b446899b87384",
|
||||
"sha256:1b805231bfb7b2900a16638c3c8b45c694334c811f84463e52451e00c9412691",
|
||||
"sha256:3a35e33902b2e6079949feed7a2dafa5ac6f019da97bd255842bb22de3c11bf5",
|
||||
"sha256:5ea034d040e6ab1d2ae04ab05a3f37dbd719c4dee3804b13903d4cc794b1336e",
|
||||
"sha256:682328aa576ec393c1872615bcb877cf32d800d4a2f150e1a5dc7e56644010b1",
|
||||
"sha256:6e06eac722676797e8fce4adb8ad3dc57a1bb3adfb0dd3fdf8306c055a38456c",
|
||||
"sha256:7eed31f4efc8356e200568ba05ad645525f1fbd8674f1e5be61a493e715e3873",
|
||||
"sha256:80cb0380838bf4e48da6adedb0c7cd060c187bb4a75f67a5aa9ec33689b84872",
|
||||
"sha256:b0b2a984bbfc543d144d88caad6cc7ff4a71be77102014bd617bd88cfb038727",
|
||||
"sha256:c196a5394c56352e21cb7224739c6dd0075b69dd56f758505951d1d8d68cf8a9",
|
||||
"sha256:d83c1d38658b0f81c282b41238092ed89d8f93c6e342224ab73fb39e16848721",
|
||||
"sha256:df7de669cbf21de4b04a3ffc9920bc8426cab4c61365fa84d79bf97401a8bef7",
|
||||
"sha256:e5db19d4a7d41bbeb3dd89b49fc1bc7e6e515b51bbf32589c618655a0ebe0bf0",
|
||||
"sha256:e695ac8c3efe124d998230b219eb51afb6ef10524a50b3c45109c4b77a8a3a92",
|
||||
"sha256:eac2a3f659d5f41d6bbfb6a97733bc7800ea5e906dc873732e00cebb98cec9e4"
|
||||
],
|
||||
"markers": "platform_python_implementation == 'CPython'",
|
||||
"version": "==0.4.16"
|
||||
},
|
||||
"gunicorn": {
|
||||
"hashes": [
|
||||
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
|
||||
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.0.4"
|
||||
"markers": "python_full_version >= '3.6.1'",
|
||||
"version": "==6.0.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.10"
|
||||
},
|
||||
"itsdangerous": {
|
||||
@@ -222,10 +213,10 @@
|
||||
},
|
||||
"lnurl": {
|
||||
"hashes": [
|
||||
"sha256:398fcb58b8112575bb24a445c61dda0e180df232717ccdbcb6a6932221cc6d16"
|
||||
"sha256:aaff8552cd4c02f8ae30c82dd3ebe6c6c3a8e06cbc59991281534ba0bbe12890"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.3.4"
|
||||
"version": "==0.3.5"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
@@ -266,6 +257,29 @@
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:2272273505f1644580fbc66c6b220cc78f893eb31f1ecde2af98ad28011e9811",
|
||||
"sha256:47911dd7c641a27160f0df5fd0fe94667160ffe97f70a42c3cc18388d86098cc"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.8.0"
|
||||
},
|
||||
"outcome": {
|
||||
"hashes": [
|
||||
"sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d",
|
||||
"sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"priority": {
|
||||
"hashes": [
|
||||
"sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe",
|
||||
"sha256:be4fcb94b5e37cdeb40af5533afe6dd603bd665fe9c8b3052610fc1001d5d1eb"
|
||||
],
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"pydantic": {
|
||||
"hashes": [
|
||||
"sha256:1783c1d927f9e1366e0e0609ae324039b2479a1a282a98ed6a6836c9ed02002c",
|
||||
@@ -296,13 +310,62 @@
|
||||
"index": "pypi",
|
||||
"version": "==1.3.7"
|
||||
},
|
||||
"requests": {
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
|
||||
"sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
|
||||
"sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d",
|
||||
"sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"
|
||||
],
|
||||
"version": "==0.14.0"
|
||||
},
|
||||
"quart": {
|
||||
"hashes": [
|
||||
"sha256:9c634e4c1e4b21b824003c676de1583581258c72b0ac4d2ba747db846e97ff56",
|
||||
"sha256:d885d782edd9d5dcfd2c4a56e020db3b82493d4c3950f91c221b7d88d239ac93"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.24.0"
|
||||
"version": "==0.13.1"
|
||||
},
|
||||
"quart-compress": {
|
||||
"hashes": [
|
||||
"sha256:41cd0cc8d26905a45025ddda7022461a71b9d1d950b21b006dc106a1c41c75ef",
|
||||
"sha256:63af5e6370aa7850fb219d22e1db89965aeb13b8f27bc83e7f9a44118faa3c54"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.2.1"
|
||||
},
|
||||
"quart-cors": {
|
||||
"hashes": [
|
||||
"sha256:020a17d504264db86cada3c1335ef174af28b33f57cee321ddc46d69c33d5c8e",
|
||||
"sha256:c08bdb326219b6c186d19ed6a97a7fd02de8fe36c7856af889494c69b525c53c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"quart-trio": {
|
||||
"hashes": [
|
||||
"sha256:00f3b20f8d82ce7e81ead61db4efba38ed7653c7e28199defded46b663ab2595",
|
||||
"sha256:dafc8f0440d4b70fa60d24122a161d2373894d2bfa9f713d9f1df1fd508f0834"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.5.1"
|
||||
},
|
||||
"rfc3986": {
|
||||
"extras": [
|
||||
"idna2008"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:112398da31a3344dc25dbf477d8df6cb34f9278a94fee2625d89e4514be8bb9d",
|
||||
"sha256:af9147e9aceda37c91a05f4deb128d4b4b49d6b199775fd2d2927768abdc8f50"
|
||||
],
|
||||
"version": "==1.4.0"
|
||||
},
|
||||
"secure": {
|
||||
"hashes": [
|
||||
"sha256:4dc8dd4b548831c3ad7f94079332c41d67c781eccc32215ff5a8a49582c1a447",
|
||||
"sha256:b3bf1e39ebf40040fc3248392343a5052aa14cb45fc87ec91b0bd11f19cc46bd"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.2.1"
|
||||
},
|
||||
"shortuuid": {
|
||||
"hashes": [
|
||||
@@ -320,29 +383,44 @@
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"sniffio": {
|
||||
"hashes": [
|
||||
"sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5",
|
||||
"sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"sortedcontainers": {
|
||||
"hashes": [
|
||||
"sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
|
||||
"sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
|
||||
],
|
||||
"version": "==2.2.2"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
|
||||
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
|
||||
],
|
||||
"version": "==0.10.1"
|
||||
},
|
||||
"trio": {
|
||||
"hashes": [
|
||||
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
|
||||
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.17.0"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5",
|
||||
"sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae",
|
||||
"sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"
|
||||
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
|
||||
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
|
||||
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
|
||||
],
|
||||
"markers": "python_version < '3.8'",
|
||||
"version": "==3.7.4.2"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
|
||||
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==1.25.10"
|
||||
},
|
||||
"webassets": {
|
||||
"hashes": [
|
||||
"sha256:167132337677c8cedc9705090f6d48da3fb262c8e0b2773b29f3352f050181cd",
|
||||
"sha256:a31a55147752ba1b3dc07dee0ad8c8efff274464e08bbdb88c1fd59ffd552724"
|
||||
],
|
||||
"version": "==2.0"
|
||||
"index": "pypi",
|
||||
"version": "==3.7.4.3"
|
||||
},
|
||||
"werkzeug": {
|
||||
"hashes": [
|
||||
@@ -351,6 +429,14 @@
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"wsproto": {
|
||||
"hashes": [
|
||||
"sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d",
|
||||
"sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.1'",
|
||||
"version": "==0.15.0"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
@@ -361,21 +447,28 @@
|
||||
],
|
||||
"version": "==1.4.4"
|
||||
},
|
||||
"async-generator": {
|
||||
"hashes": [
|
||||
"sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b",
|
||||
"sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.10"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
|
||||
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
|
||||
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
|
||||
"sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==19.3.0"
|
||||
"version": "==20.2.0"
|
||||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
|
||||
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
|
||||
"sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==19.10b0"
|
||||
"version": "==20.8b1"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
@@ -387,67 +480,50 @@
|
||||
},
|
||||
"coverage": {
|
||||
"hashes": [
|
||||
"sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb",
|
||||
"sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3",
|
||||
"sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716",
|
||||
"sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034",
|
||||
"sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3",
|
||||
"sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8",
|
||||
"sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0",
|
||||
"sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f",
|
||||
"sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4",
|
||||
"sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962",
|
||||
"sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d",
|
||||
"sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b",
|
||||
"sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4",
|
||||
"sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3",
|
||||
"sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258",
|
||||
"sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59",
|
||||
"sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01",
|
||||
"sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd",
|
||||
"sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b",
|
||||
"sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d",
|
||||
"sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89",
|
||||
"sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd",
|
||||
"sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b",
|
||||
"sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d",
|
||||
"sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46",
|
||||
"sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546",
|
||||
"sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082",
|
||||
"sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b",
|
||||
"sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4",
|
||||
"sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8",
|
||||
"sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811",
|
||||
"sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd",
|
||||
"sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651",
|
||||
"sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"
|
||||
"sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
|
||||
"sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
|
||||
"sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
|
||||
"sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
|
||||
"sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
|
||||
"sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
|
||||
"sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
|
||||
"sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
|
||||
"sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
|
||||
"sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
|
||||
"sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
|
||||
"sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
|
||||
"sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
|
||||
"sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
|
||||
"sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
|
||||
"sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
|
||||
"sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
|
||||
"sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
|
||||
"sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
|
||||
"sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
|
||||
"sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
|
||||
"sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
|
||||
"sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
|
||||
"sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
|
||||
"sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
|
||||
"sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
|
||||
"sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
|
||||
"sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
|
||||
"sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
|
||||
"sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
|
||||
"sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
|
||||
"sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
|
||||
"sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
|
||||
"sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==5.2.1"
|
||||
"version": "==5.3"
|
||||
},
|
||||
"flake8": {
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c",
|
||||
"sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.8.3"
|
||||
},
|
||||
"flake8-mypy": {
|
||||
"hashes": [
|
||||
"sha256:47120db63aff631ee1f84bac6fe8e64731dc66da3efc1c51f85e15ade4a3ba18",
|
||||
"sha256:cff009f4250e8391bf48990093cff85802778c345c8449d6498b62efefeebcbc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==17.8.0"
|
||||
},
|
||||
"importlib-metadata": {
|
||||
"hashes": [
|
||||
"sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83",
|
||||
"sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"
|
||||
],
|
||||
"markers": "python_version < '3.8'",
|
||||
"version": "==1.7.0"
|
||||
"version": "==2.10"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
@@ -456,40 +532,25 @@
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"mccabe": {
|
||||
"hashes": [
|
||||
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
|
||||
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
|
||||
],
|
||||
"version": "==0.6.1"
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5",
|
||||
"sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==8.4.0"
|
||||
},
|
||||
"mypy": {
|
||||
"hashes": [
|
||||
"sha256:2c6cde8aa3426c1682d35190b59b71f661237d74b053822ea3d748e2c9578a7c",
|
||||
"sha256:3fdda71c067d3ddfb21da4b80e2686b71e9e5c72cca65fa216d207a358827f86",
|
||||
"sha256:5dd13ff1f2a97f94540fd37a49e5d255950ebcdf446fb597463a40d0df3fac8b",
|
||||
"sha256:6731603dfe0ce4352c555c6284c6db0dc935b685e9ce2e4cf220abe1e14386fd",
|
||||
"sha256:6bb93479caa6619d21d6e7160c552c1193f6952f0668cdda2f851156e85186fc",
|
||||
"sha256:81c7908b94239c4010e16642c9102bfc958ab14e36048fa77d0be3289dda76ea",
|
||||
"sha256:9c7a9a7ceb2871ba4bac1cf7217a7dd9ccd44c27c2950edbc6dc08530f32ad4e",
|
||||
"sha256:a4a2cbcfc4cbf45cd126f531dedda8485671545b43107ded25ce952aac6fb308",
|
||||
"sha256:b7fbfabdbcc78c4f6fc4712544b9b0d6bf171069c6e0e3cb82440dd10ced3406",
|
||||
"sha256:c05b9e4fb1d8a41d41dec8786c94f3b95d3c5f528298d769eb8e73d293abc48d",
|
||||
"sha256:d7df6eddb6054d21ca4d3c6249cae5578cb4602951fd2b6ee2f5510ffb098707",
|
||||
"sha256:e0b61738ab504e656d1fe4ff0c0601387a5489ca122d55390ade31f9ca0e252d",
|
||||
"sha256:eff7d4a85e9eea55afa34888dfeaccde99e7520b51f867ac28a48492c0b1130c",
|
||||
"sha256:f05644db6779387ccdb468cc47a44b4356fc2ffa9287135d05b70a98dc83b89a"
|
||||
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
|
||||
"sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7",
|
||||
"sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2",
|
||||
"sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474",
|
||||
"sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0",
|
||||
"sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217",
|
||||
"sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749",
|
||||
"sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6",
|
||||
"sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf",
|
||||
"sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36",
|
||||
"sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b",
|
||||
"sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72",
|
||||
"sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1",
|
||||
"sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==0.782"
|
||||
"index": "pypi",
|
||||
"version": "==0.761"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
@@ -498,6 +559,14 @@
|
||||
],
|
||||
"version": "==0.4.3"
|
||||
},
|
||||
"outcome": {
|
||||
"hashes": [
|
||||
"sha256:ee46c5ce42780cde85d55a61819d0e6b8cb490f1dbd749ba75ff2629771dcd2d",
|
||||
"sha256:fc7822068ba7dd0fc2532743611e8a73246708d3564e29a39f93d6ab3701b66f"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
|
||||
@@ -529,22 +598,6 @@
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"pycodestyle": {
|
||||
"hashes": [
|
||||
"sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
|
||||
"sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"pyflakes": {
|
||||
"hashes": [
|
||||
"sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
|
||||
"sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.2.0"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
@@ -555,11 +608,11 @@
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4",
|
||||
"sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad"
|
||||
"sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9",
|
||||
"sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==6.0.1"
|
||||
"version": "==6.1.1"
|
||||
},
|
||||
"pytest-cov": {
|
||||
"hashes": [
|
||||
@@ -569,31 +622,45 @@
|
||||
"index": "pypi",
|
||||
"version": "==2.10.1"
|
||||
},
|
||||
"pytest-trio": {
|
||||
"hashes": [
|
||||
"sha256:3f48cc1df66d279d705af38ad38d1639c2e2380ddffcdc3a45bb81758de61f03",
|
||||
"sha256:9bf0a490fd177a33617e8709242293fae47934de2b51f8209eb2c0545b6ca8fe"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.6.0"
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
"sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204",
|
||||
"sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162",
|
||||
"sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f",
|
||||
"sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb",
|
||||
"sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6",
|
||||
"sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7",
|
||||
"sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88",
|
||||
"sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99",
|
||||
"sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644",
|
||||
"sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a",
|
||||
"sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840",
|
||||
"sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067",
|
||||
"sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd",
|
||||
"sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4",
|
||||
"sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e",
|
||||
"sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89",
|
||||
"sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e",
|
||||
"sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc",
|
||||
"sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf",
|
||||
"sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341",
|
||||
"sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"
|
||||
"sha256:088afc8c63e7bd187a3c70a94b9e50ab3f17e1d3f52a32750b5b77dbe99ef5ef",
|
||||
"sha256:1fe0a41437bbd06063aa184c34804efa886bcc128222e9916310c92cd54c3b4c",
|
||||
"sha256:3d20024a70b97b4f9546696cbf2fd30bae5f42229fbddf8661261b1eaff0deb7",
|
||||
"sha256:41bb65f54bba392643557e617316d0d899ed5b4946dccee1cb6696152b29844b",
|
||||
"sha256:4318d56bccfe7d43e5addb272406ade7a2274da4b70eb15922a071c58ab0108c",
|
||||
"sha256:4707f3695b34335afdfb09be3802c87fa0bc27030471dbc082f815f23688bc63",
|
||||
"sha256:49f23ebd5ac073765ecbcf046edc10d63dcab2f4ae2bce160982cb30df0c0302",
|
||||
"sha256:5533a959a1748a5c042a6da71fe9267a908e21eded7a4f373efd23a2cbdb0ecc",
|
||||
"sha256:5d892a4f1c999834eaa3c32bc9e8b976c5825116cde553928c4c8e7e48ebda67",
|
||||
"sha256:5f18875ac23d9aa2f060838e8b79093e8bb2313dbaaa9f54c6d8e52a5df097be",
|
||||
"sha256:60b0e9e6dc45683e569ec37c55ac20c582973841927a85f2d8a7d20ee80216ab",
|
||||
"sha256:816064fc915796ea1f26966163f6845de5af78923dfcecf6551e095f00983650",
|
||||
"sha256:84cada8effefe9a9f53f9b0d2ba9b7b6f5edf8d2155f9fdbe34616e06ececf81",
|
||||
"sha256:84e9407db1b2eb368b7ecc283121b5e592c9aaedbe8c78b1a2f1102eb2e21d19",
|
||||
"sha256:8d69cef61fa50c8133382e61fd97439de1ae623fe943578e477e76a9d9471637",
|
||||
"sha256:9a02d0ae31d35e1ec12a4ea4d4cca990800f66a917d0fb997b20fbc13f5321fc",
|
||||
"sha256:9bc13e0d20b97ffb07821aa3e113f9998e84994fe4d159ffa3d3a9d1b805043b",
|
||||
"sha256:a6f32aea4260dfe0e55dc9733ea162ea38f0ea86aa7d0f77b15beac5bf7b369d",
|
||||
"sha256:ae91972f8ac958039920ef6e8769277c084971a142ce2b660691793ae44aae6b",
|
||||
"sha256:c570f6fa14b9c4c8a4924aaad354652366577b4f98213cf76305067144f7b100",
|
||||
"sha256:c9443124c67b1515e4fe0bb0aa18df640965e1030f468a2a5dc2589b26d130ad",
|
||||
"sha256:d23a18037313714fb3bb5a94434d3151ee4300bae631894b1ac08111abeaa4a3",
|
||||
"sha256:eaf548d117b6737df379fdd53bdde4f08870e66d7ea653e230477f071f861121",
|
||||
"sha256:ebbe29186a3d9b0c591e71b7393f1ae08c83cb2d8e517d2a822b8f7ec99dfd8b",
|
||||
"sha256:eda4771e0ace7f67f58bc5b560e27fb20f32a148cbc993b0c3835970935c2707",
|
||||
"sha256:f1b3afc574a3db3b25c89161059d857bd4909a1269b0b3cb3c904677c8c4a3f7",
|
||||
"sha256:f2388013e68e750eaa16ccbea62d4130180c26abb1d8e5d584b9baf69672b30f"
|
||||
],
|
||||
"version": "==2020.7.14"
|
||||
"version": "==2020.9.27"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
@@ -603,6 +670,21 @@
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"sniffio": {
|
||||
"hashes": [
|
||||
"sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5",
|
||||
"sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"sortedcontainers": {
|
||||
"hashes": [
|
||||
"sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba",
|
||||
"sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f"
|
||||
],
|
||||
"version": "==2.2.2"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
|
||||
@@ -610,6 +692,14 @@
|
||||
],
|
||||
"version": "==0.10.1"
|
||||
},
|
||||
"trio": {
|
||||
"hashes": [
|
||||
"sha256:e85cf9858e445465dfbb0e3fdf36efe92082d2df87bfe9d62585eedd6e8e9d7d",
|
||||
"sha256:fc70c74e8736d1105b3c05cc2e49b30c58755733740f9c51ae6d88a4d6d0a291"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.17.0"
|
||||
},
|
||||
"typed-ast": {
|
||||
"hashes": [
|
||||
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
|
||||
@@ -638,20 +728,12 @@
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5",
|
||||
"sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae",
|
||||
"sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"
|
||||
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
|
||||
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
|
||||
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
|
||||
],
|
||||
"markers": "python_version < '3.8'",
|
||||
"version": "==3.7.4.2"
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
"sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
|
||||
"sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.1.0"
|
||||
"index": "pypi",
|
||||
"version": "==3.7.4.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
web: gunicorn -b :5000 lnbits:app -k gevent
|
||||
web: hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
LNbits
|
||||
======
|
||||
|
||||
[![github-actions-badge]][github-actions]
|
||||
[![github-tests-badge]][github-tests]
|
||||
[![github-mypy-badge]][github-mypy]
|
||||
[![codecov-badge]][codecov]
|
||||
[![license-badge]](LICENSE)
|
||||
[![docs-badge]][docs]
|
||||
|
||||
|
||||

|
||||
|
||||
@@ -19,13 +22,21 @@ LNbits is a very simple Python server that sits on top of any funding source, an
|
||||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
|
||||
The wallet can run on top of any lightning-network funding source, currently there is support for LND, CLightning, Lntxbot, LNpay, OpenNode, with more being added regularily.
|
||||
The wallet can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularily.
|
||||
|
||||
See [lnbits.org](https://lnbits.org) for more detailed documentation.
|
||||
|
||||
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
|
||||
|
||||
LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as excellent funding sources for LNbits!
|
||||
LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as excellent funding sources for LNbits.
|
||||
|
||||
## Running LNbits
|
||||
|
||||
See the [install guide](docs/guide/installation.md) for details on installation and setup.
|
||||
|
||||
### Contributing to LNbits
|
||||
|
||||
There's a [slightly different setup](docs/devs/installation.md) if you want to contribute to LNbits, but if your changes don't require adding or removing any package dependencies you don't have to bother with that, just follow the [normal installation](docs/guide/installation.md) steps.
|
||||
|
||||
## LNbits as an account system
|
||||
|
||||
@@ -57,16 +68,17 @@ Wallets can be easily generated and given out to people at events (one click mul
|
||||
|
||||

|
||||
|
||||
## Running LNbits locally
|
||||
|
||||
See the [development docs](docs/devs/installation.md) for details on installation and setup.
|
||||
|
||||
## Tip me
|
||||
|
||||
If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
|
||||
|
||||
[github-actions]: https://github.com/lnbits/lnbits/actions
|
||||
[github-actions-badge]: https://github.com/lnbits/lnbits/workflows/test%20suite/badge.svg
|
||||
|
||||
[docs]: https://lnbits.org/
|
||||
[docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
|
||||
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
|
||||
[github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
|
||||
[github-tests]: https://github.com/lnbits/lnbits/actions?query=workflow%3Atests
|
||||
[github-tests-badge]: https://github.com/lnbits/lnbits/workflows/tests/badge.svg
|
||||
[codecov]: https://codecov.io/gh/lnbits/lnbits
|
||||
[codecov-badge]: https://codecov.io/gh/lnbits/lnbits/branch/master/graph/badge.svg
|
||||
[license-badge]: https://img.shields.io/badge/license-MIT-blue.svg
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dokku": {
|
||||
"predeploy": "flask migrate"
|
||||
"predeploy": "quart migrate && quart assets"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: For developers
|
||||
nav_order: 3
|
||||
nav_order: 4
|
||||
has_children: true
|
||||
---
|
||||
|
||||
|
||||
+20
-1
@@ -19,6 +19,25 @@ find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change al
|
||||
Going over the example extension's structure:
|
||||
* views_api.py: This is where your public API would go. It will be exposed at "$DOMAIN/$PLUGIN/$ROUTE". For example: https://lnbits.com/mysuperplugin/api/v1/tools.
|
||||
* views.py: The `/` path will show up as your plugin's home page in lnbits' UI. Other pages you can define yourself. The `templates` folder should explain itself in relation to this.
|
||||
* migrations.py: Create database tables for your plugin. They'll be created when you run `pipenv run flask migrate`.
|
||||
* migrations.py: Create database tables for your plugin. They'll be created automatically when you start lnbits.
|
||||
|
||||
... This document is a work-in-progress. Send pull requests if you get stuck, so others don't.
|
||||
|
||||
|
||||
Adding new dependencies
|
||||
-----------------------
|
||||
|
||||
If for some reason your extensions needs a new python package to work, you can add a new package using Pipenv:
|
||||
|
||||
```sh
|
||||
$ pipenv install new_package_name
|
||||
```
|
||||
|
||||
This will create a new entry in the `Pipenv` file.
|
||||
**But we need an extra step to make sure LNbits doesn't break in production.**
|
||||
All tests and deployments should run against the `requirements.txt` file so every time a new package is added
|
||||
it is necessary to run the Pipenv `lock` command and manually update the requirements file:
|
||||
|
||||
```sh
|
||||
$ pipenv lock -r
|
||||
```
|
||||
|
||||
+20
-14
@@ -5,10 +5,12 @@ title: Installation
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
Download this repo and install the dependencies.
|
||||
Download the latest stable release https://github.com/lnbits/lnbits/releases
|
||||
|
||||
|
||||
Application dependencies
|
||||
------------------------
|
||||
@@ -20,35 +22,39 @@ While in development, you will need to install all dependencies:
|
||||
$ pipenv shell
|
||||
$ pipenv install --dev
|
||||
```
|
||||
|
||||
If you wish to use a version of Python higher than 3.7:
|
||||
|
||||
```sh
|
||||
$ pipenv --python 3.8 install --dev
|
||||
```
|
||||
You will need to set the variables in `.env.example`, and rename the file to `.env`.
|
||||
|
||||
You will need to copy `.env.example` to `.env`, then set variables there.
|
||||
|
||||

|
||||
|
||||
You might also need to install additional packages, depending on the [backend wallets](../guide/wallets.md) you configured.
|
||||
E.g. when you want to use LND you have to `pipenv install lnd-grpc`.
|
||||
You might also need to install additional packages, depending on the [backend wallet](../guide/wallets.md) you use.
|
||||
E.g. when you want to use LND you have to `pipenv run pip install lndgrpc` and `pipenv run pip install purerpc`.
|
||||
|
||||
Take a look at [Polar][polar] for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
Take a look at [Polar](https://lightningpolar.com/) for an excellent way of spinning up a Lightning Network dev environment.
|
||||
|
||||
Running the server
|
||||
------------------
|
||||
|
||||
LNbits uses [Flask](http://flask.pocoo.org/) as an application server.
|
||||
LNbits uses [Quart][quart] as an application server.
|
||||
|
||||
```sh
|
||||
$ pipenv run flask migrate
|
||||
$ pipenv run flask run
|
||||
$ pipenv run python -m lnbits
|
||||
```
|
||||
|
||||
There is an environment variable called `FLASK_ENV` that has to be set to `development`
|
||||
if you want to run Flask in debug mode with autoreload
|
||||
|
||||
[pipenv]: https://pipenv.pypa.io/
|
||||
|
||||
Frontend
|
||||
--------
|
||||
|
||||
The views are build using [Vue.js and Quasar](https://quasar.dev/start/how-to-use-vue).
|
||||
The frontend uses [Vue.js and Quasar][quasar].
|
||||
|
||||
|
||||
[quart]: https://pgjones.gitlab.io/
|
||||
[pipenv]: https://pipenv.pypa.io/
|
||||
[polar]: https://lightningpolar.com/
|
||||
[quasar]: https://quasar.dev/start/how-to-use-vue
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
layout: default
|
||||
title: Basic installation
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
|
||||
Basic installation
|
||||
==================
|
||||
|
||||
Download this repo and install the dependencies:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits.git
|
||||
cd lnbits/
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
./venv/bin/quart assets
|
||||
./venv/bin/quart migrate
|
||||
./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
|
||||
```
|
||||
|
||||
No you can visit your LNbits at http://localhost:5000/.
|
||||
|
||||
Now modify the `.env` file with any settings you prefer and add a proper [funding source](./wallets.md) by modifying the value of `LNBITS_BACKEND_WALLET_CLASS` and providing the extra information and credentials related to the chosen funding source.
|
||||
|
||||
Then you can run restart it and it will be using the new settings.
|
||||
|
||||
You might also need to install additional packages or perform additional setup steps, depending on the chosen backend. See [the short guide](./wallets.md) on each different funding source.
|
||||
+24
-25
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: Backend wallets
|
||||
nav_order: 2
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
|
||||
@@ -21,59 +21,58 @@ Using this wallet requires the installation of the `pylightning` Python package.
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet**
|
||||
- `CLIGHTNING_RPC`: /file/path/lightning-rpc
|
||||
|
||||
### Spark (c-lightning)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
|
||||
- `SPARK_URL`: http://10.147.17.230:9737/rpc
|
||||
- `SPARK_TOKEN`: secret_access_key
|
||||
|
||||
### LND (gRPC)
|
||||
|
||||
Using this wallet requires the installation of the `lnd-grpc` Python package.
|
||||
Using this wallet requires the installation of the `lndgrpc` and `purerpc` Python packages.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `LND_CERT`: /file/path/tls.cert
|
||||
- `LND_ADMIN_MACAROON`: /file/path/admin.macaroon
|
||||
- `LND_INVOICE_MACAROON`: /file/path/invoice.macaroon
|
||||
- `LND_READ_MACAROON`: /file/path/read.macaroon
|
||||
- `LND_GRPC_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
|
||||
|
||||
|
||||
### LND (REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: ip_address
|
||||
- `LND_CERT`: /file/path/tls.cert
|
||||
- `LND_ADMIN_MACAROON`: /file/path/admin.macaroon
|
||||
- `LND_INVOICE_MACAROON`: /file/path/invoice.macaroon
|
||||
- `LND_READ_MACAROON`: /file/path/read.macaroon
|
||||
- `LND_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
|
||||
|
||||
|
||||
### LNbits
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
||||
- `LNBITS_ENDPOINT`: ex. https://lnbits.com
|
||||
- `LNBITS_ADMIN_KEY`: apiKey
|
||||
- `LNBITS_INVOICE_KEY`: apiKey
|
||||
|
||||
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
|
||||
- `LNBITS_KEY`: lnbitsAdminKey
|
||||
|
||||
### LNPay
|
||||
|
||||
For the invoice listener to work you have a publicly accessible URL in your LNbits and must set up [LNPay webhooks](https://lnpay.co/webhook/) pointing to `<your LNbits host>/wallet/webhook` with the "Wallet Receive" event and no secret.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
|
||||
- `LNPAY_API_ENDPOINT`: ex. https://lnpay.co/v1/
|
||||
- `LNPAY_API_KEY`: apiKey
|
||||
- `LNPAY_ADMIN_KEY`: apiKey
|
||||
- `LNPAY_INVOICE_KEY`: apiKey
|
||||
- `LNPAY_READ_KEY`: apiKey
|
||||
- `LNPAY_API_ENDPOINT`: https://lnpay.co/v1/
|
||||
- `LNPAY_API_KEY`: sak_apiKey
|
||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||
|
||||
|
||||
### lntxbot
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
|
||||
- `LNTXBOT_API_ENDPOINT`: ex. https://lntxbot.bigsun.xyz/
|
||||
- `LNTXBOT_ADMIN_KEY`: apiKey
|
||||
- `LNTXBOT_INVOICE_KEY`: apiKey
|
||||
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.bigsun.xyz/
|
||||
- `LNTXBOT_KEY`: lntxbotAdminApiKey
|
||||
|
||||
|
||||
### OpenNode
|
||||
|
||||
For the invoice to work you must have a publicly accessible URL in your LNbits. No manual webhook setting is necessary.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
|
||||
- `OPENNODE_API_ENDPOINT`: ex. https://api.opennode.com/
|
||||
- `OPENNODE_ADMIN_KEY`: apiKey
|
||||
- `OPENNODE_INVOICE_KEY`: apiKey
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import importlib
|
||||
|
||||
from flask import Flask
|
||||
from flask_assets import Environment, Bundle # type: ignore
|
||||
from flask_compress import Compress # type: ignore
|
||||
from flask_cors import CORS # type: ignore
|
||||
from flask_talisman import Talisman # type: ignore
|
||||
from os import getenv
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from .core import core_app, migrations as core_migrations
|
||||
from .helpers import ExtensionManager
|
||||
from .settings import FORCE_HTTPS
|
||||
|
||||
|
||||
disabled_extensions = getenv("LNBITS_DISABLED_EXTENSIONS", "").split(",")
|
||||
|
||||
app = Flask(__name__)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) # type: ignore
|
||||
valid_extensions = [ext for ext in ExtensionManager(disabled=disabled_extensions).extensions if ext.is_valid]
|
||||
|
||||
|
||||
# optimization & security
|
||||
# -----------------------
|
||||
|
||||
Compress(app)
|
||||
CORS(app)
|
||||
Talisman(
|
||||
app,
|
||||
force_https=FORCE_HTTPS,
|
||||
content_security_policy={
|
||||
"default-src": [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
"blob:",
|
||||
"api.opennode.co",
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# blueprints / extensions
|
||||
# -----------------------
|
||||
|
||||
app.register_blueprint(core_app)
|
||||
|
||||
for ext in valid_extensions:
|
||||
try:
|
||||
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
|
||||
app.register_blueprint(getattr(ext_module, f"{ext.code}_ext"), url_prefix=f"/{ext.code}")
|
||||
except Exception:
|
||||
raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.")
|
||||
|
||||
|
||||
# filters
|
||||
# -------
|
||||
|
||||
app.jinja_env.globals["DEBUG"] = app.config["DEBUG"]
|
||||
app.jinja_env.globals["EXTENSIONS"] = valid_extensions
|
||||
app.jinja_env.globals["SITE_TITLE"] = getenv("LNBITS_SITE_TITLE", "LNbits")
|
||||
|
||||
|
||||
# assets
|
||||
# ------
|
||||
|
||||
assets = Environment(app)
|
||||
assets.url = app.static_url_path
|
||||
assets.register("base_css", Bundle("scss/base.scss", filters="pyscss", output="css/base.css"))
|
||||
|
||||
|
||||
# commands
|
||||
# --------
|
||||
|
||||
@app.cli.command("migrate")
|
||||
def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
core_migrations.migrate()
|
||||
|
||||
for ext in valid_extensions:
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"lnbits.extensions.{ext.code}.migrations")
|
||||
ext_migrations.migrate()
|
||||
except Exception:
|
||||
raise ImportError(f"Please make sure that the extension `{ext.code}` has a migrations file.")
|
||||
|
||||
|
||||
# init
|
||||
# ----
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from .app import create_app
|
||||
from .commands import migrate_databases, transpile_scss, bundle_vendored
|
||||
from .settings import LNBITS_SITE_TITLE, SERVICE_FEE, DEBUG, LNBITS_DATA_FOLDER, WALLET, LNBITS_COMMIT
|
||||
|
||||
migrate_databases()
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
app = create_app()
|
||||
|
||||
print(
|
||||
f"""Starting LNbits with
|
||||
- git version: {LNBITS_COMMIT}
|
||||
- site title: {LNBITS_SITE_TITLE}
|
||||
- debug: {DEBUG}
|
||||
- data folder: {LNBITS_DATA_FOLDER}
|
||||
- funding source: {WALLET.__class__.__name__}
|
||||
- service fee: {SERVICE_FEE}
|
||||
"""
|
||||
)
|
||||
|
||||
app.run(host=app.config["HOST"], port=app.config["PORT"])
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dokku": {
|
||||
"predeploy": "flask migrate"
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import importlib
|
||||
import warnings
|
||||
|
||||
from quart import g
|
||||
from quart_trio import QuartTrio
|
||||
from quart_cors import cors # type: ignore
|
||||
from quart_compress import Compress # type: ignore
|
||||
from secure import SecureHeaders # type: ignore
|
||||
|
||||
from .commands import db_migrate, handle_assets
|
||||
from .core import core_app
|
||||
from .db import open_db, open_ext_db
|
||||
from .helpers import get_valid_extensions, get_js_vendored, get_css_vendored, url_for_vendored
|
||||
from .proxy_fix import ASGIProxyFix
|
||||
from .tasks import run_deferred_async, invoice_listener, internal_invoice_listener, webhook_handler, grab_app_for_later
|
||||
from .settings import WALLET
|
||||
|
||||
secure_headers = SecureHeaders(hsts=False)
|
||||
|
||||
|
||||
def create_app(config_object="lnbits.settings") -> QuartTrio:
|
||||
"""Create application factory.
|
||||
:param config_object: The configuration object to use.
|
||||
"""
|
||||
app = QuartTrio(__name__, static_folder="static")
|
||||
app.config.from_object(config_object)
|
||||
app.asgi_http_class = ASGIProxyFix
|
||||
|
||||
cors(app)
|
||||
Compress(app)
|
||||
|
||||
check_funding_source(app)
|
||||
register_assets(app)
|
||||
register_blueprints(app)
|
||||
register_filters(app)
|
||||
register_commands(app)
|
||||
register_request_hooks(app)
|
||||
register_async_tasks(app)
|
||||
grab_app_for_later(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def check_funding_source(app: QuartTrio) -> None:
|
||||
@app.before_serving
|
||||
async def check_wallet_status():
|
||||
error_message, balance = WALLET.status()
|
||||
if error_message:
|
||||
warnings.warn(
|
||||
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
else:
|
||||
print(f" ✔️ {WALLET.__class__.__name__} seems to be connected and with a balance of {balance} msat.")
|
||||
|
||||
|
||||
def register_blueprints(app: QuartTrio) -> None:
|
||||
"""Register Flask blueprints / LNbits extensions."""
|
||||
app.register_blueprint(core_app)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
try:
|
||||
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
|
||||
bp = getattr(ext_module, f"{ext.code}_ext")
|
||||
|
||||
@bp.before_request
|
||||
async def before_request():
|
||||
g.ext_db = open_ext_db(ext.code)
|
||||
|
||||
@bp.teardown_request
|
||||
async def after_request(exc):
|
||||
g.ext_db.close()
|
||||
|
||||
app.register_blueprint(bp, url_prefix=f"/{ext.code}")
|
||||
except Exception:
|
||||
raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.")
|
||||
|
||||
|
||||
def register_commands(app: QuartTrio):
|
||||
"""Register Click commands."""
|
||||
app.cli.add_command(db_migrate)
|
||||
app.cli.add_command(handle_assets)
|
||||
|
||||
|
||||
def register_assets(app: QuartTrio):
|
||||
"""Serve each vendored asset separately or a bundle."""
|
||||
|
||||
@app.before_request
|
||||
async def vendored_assets_variable():
|
||||
if app.config["DEBUG"]:
|
||||
g.VENDORED_JS = map(url_for_vendored, get_js_vendored())
|
||||
g.VENDORED_CSS = map(url_for_vendored, get_css_vendored())
|
||||
else:
|
||||
g.VENDORED_JS = ["/static/bundle.js"]
|
||||
g.VENDORED_CSS = ["/static/bundle.css"]
|
||||
|
||||
|
||||
def register_filters(app: QuartTrio):
|
||||
"""Jinja filters."""
|
||||
app.jinja_env.globals["SITE_TITLE"] = app.config["LNBITS_SITE_TITLE"]
|
||||
app.jinja_env.globals["EXTENSIONS"] = get_valid_extensions()
|
||||
|
||||
|
||||
def register_request_hooks(app: QuartTrio):
|
||||
"""Open the core db for each request so everything happens in a big transaction"""
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
g.db = open_db()
|
||||
g.nursery = app.nursery
|
||||
|
||||
@app.after_request
|
||||
async def set_secure_headers(response):
|
||||
secure_headers.quart(response)
|
||||
return response
|
||||
|
||||
@app.teardown_request
|
||||
async def after_request(exc):
|
||||
g.db.close()
|
||||
|
||||
|
||||
def register_async_tasks(app):
|
||||
@app.route("/wallet/webhook", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
||||
async def webhook_listener():
|
||||
return await webhook_handler()
|
||||
|
||||
@app.before_serving
|
||||
async def listeners():
|
||||
run_deferred_async(app.nursery)
|
||||
app.nursery.start_soon(invoice_listener)
|
||||
app.nursery.start_soon(internal_invoice_listener)
|
||||
|
||||
@app.after_serving
|
||||
async def stop_listeners():
|
||||
pass
|
||||
+95
-32
@@ -1,66 +1,121 @@
|
||||
# type: ignore
|
||||
|
||||
import bitstring
|
||||
import bitstring # type: ignore
|
||||
import re
|
||||
from binascii import hexlify
|
||||
from bech32 import bech32_decode, CHARSET
|
||||
import hashlib
|
||||
from typing import List, NamedTuple, Optional
|
||||
from bech32 import bech32_decode, CHARSET # type: ignore
|
||||
from ecdsa import SECP256k1, VerifyingKey # type: ignore
|
||||
from ecdsa.util import sigdecode_string # type: ignore
|
||||
from binascii import unhexlify
|
||||
|
||||
|
||||
class Route(NamedTuple):
|
||||
pubkey: str
|
||||
short_channel_id: str
|
||||
base_fee_msat: int
|
||||
ppm_fee: int
|
||||
cltv: int
|
||||
|
||||
|
||||
class Invoice(object):
|
||||
def __init__(self):
|
||||
self.payment_hash: str = None
|
||||
self.amount_msat: int = 0
|
||||
self.description: str = None
|
||||
payment_hash: str
|
||||
amount_msat: int = 0
|
||||
description: Optional[str] = None
|
||||
description_hash: Optional[str] = None
|
||||
payee: Optional[str] = None
|
||||
date: int
|
||||
expiry: int = 3600
|
||||
secret: Optional[str] = None
|
||||
route_hints: List[Route] = []
|
||||
min_final_cltv_expiry: int = 18
|
||||
|
||||
|
||||
def decode(pr: str) -> Invoice:
|
||||
""" Super naïve bolt11 decoder,
|
||||
only gets payment_hash, description/description_hash and amount in msatoshi.
|
||||
"""bolt11 decoder,
|
||||
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py
|
||||
"""
|
||||
hrp, data = bech32_decode(pr)
|
||||
if not hrp:
|
||||
raise ValueError("Bad bech32 checksum")
|
||||
|
||||
hrp, decoded_data = bech32_decode(pr)
|
||||
if hrp is None or decoded_data is None:
|
||||
raise ValueError("Bad bech32 checksum")
|
||||
if not hrp.startswith("ln"):
|
||||
raise ValueError("Does not start with ln")
|
||||
|
||||
data = u5_to_bitarray(data)
|
||||
bitarray = _u5_to_bitarray(decoded_data)
|
||||
|
||||
# Final signature 65 bytes, split it off.
|
||||
if len(data) < 65 * 8:
|
||||
# final signature 65 bytes, split it off.
|
||||
if len(bitarray) < 65 * 8:
|
||||
raise ValueError("Too short to contain signature")
|
||||
data = bitstring.ConstBitStream(data[: -65 * 8])
|
||||
|
||||
# extract the signature
|
||||
signature = bitarray[-65 * 8 :].tobytes()
|
||||
|
||||
# the tagged fields as a bitstream
|
||||
data = bitstring.ConstBitStream(bitarray[: -65 * 8])
|
||||
|
||||
# build the invoice object
|
||||
invoice = Invoice()
|
||||
|
||||
# decode the amount from the hrp
|
||||
m = re.search("[^\d]+", hrp[2:])
|
||||
if m:
|
||||
amountstr = hrp[2 + m.end() :]
|
||||
if amountstr != "":
|
||||
invoice.amount_msat = unshorten_amount(amountstr)
|
||||
invoice.amount_msat = _unshorten_amount(amountstr)
|
||||
|
||||
# pull out date
|
||||
data.read(35).uint
|
||||
invoice.date = data.read(35).uint
|
||||
|
||||
while data.pos != data.len:
|
||||
tag, tagdata, data = pull_tagged(data)
|
||||
|
||||
tag, tagdata, data = _pull_tagged(data)
|
||||
data_length = len(tagdata) / 5
|
||||
|
||||
if tag == "d":
|
||||
invoice.description = trim_to_bytes(tagdata).decode("utf-8")
|
||||
invoice.description = _trim_to_bytes(tagdata).decode("utf-8")
|
||||
elif tag == "h" and data_length == 52:
|
||||
invoice.description = hexlify(trim_to_bytes(tagdata)).decode("ascii")
|
||||
invoice.description_hash = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "p" and data_length == 52:
|
||||
invoice.payment_hash = hexlify(trim_to_bytes(tagdata)).decode("ascii")
|
||||
invoice.payment_hash = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "x":
|
||||
invoice.expiry = tagdata.uint
|
||||
elif tag == "n":
|
||||
invoice.payee = _trim_to_bytes(tagdata).hex()
|
||||
# this won't work in most cases, we must extract the payee
|
||||
# from the signature
|
||||
elif tag == "s":
|
||||
invoice.secret = _trim_to_bytes(tagdata).hex()
|
||||
elif tag == "r":
|
||||
s = bitstring.ConstBitStream(tagdata)
|
||||
while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
|
||||
route = Route(
|
||||
pubkey=s.read(264).tobytes().hex(),
|
||||
short_channel_id=_readable_scid(s.read(64).intbe),
|
||||
base_fee_msat=s.read(32).intbe,
|
||||
ppm_fee=s.read(32).intbe,
|
||||
cltv=s.read(16).intbe,
|
||||
)
|
||||
invoice.route_hints.append(route)
|
||||
|
||||
# BOLT #11:
|
||||
# A reader MUST check that the `signature` is valid (see the `n` tagged
|
||||
# field specified below).
|
||||
# A reader MUST use the `n` field to validate the signature instead of
|
||||
# performing signature recovery if a valid `n` field is provided.
|
||||
message = bytearray([ord(c) for c in hrp]) + data.tobytes()
|
||||
sig = signature[0:64]
|
||||
if invoice.payee:
|
||||
key = VerifyingKey.from_string(unhexlify(invoice.payee), curve=SECP256k1)
|
||||
key.verify(sig, message, hashlib.sha256, sigdecode=sigdecode_string)
|
||||
else:
|
||||
keys = VerifyingKey.from_public_key_recovery(sig, message, SECP256k1, hashlib.sha256)
|
||||
signaling_byte = signature[64]
|
||||
key = keys[int(signaling_byte)]
|
||||
invoice.payee = key.to_string("compressed").hex()
|
||||
|
||||
return invoice
|
||||
|
||||
|
||||
def unshorten_amount(amount: str) -> int:
|
||||
""" Given a shortened amount, return millisatoshis
|
||||
"""
|
||||
def _unshorten_amount(amount: str) -> int:
|
||||
"""Given a shortened amount, return millisatoshis"""
|
||||
# BOLT #11:
|
||||
# The following `multiplier` letters are defined:
|
||||
#
|
||||
@@ -83,18 +138,18 @@ def unshorten_amount(amount: str) -> int:
|
||||
raise ValueError("Invalid amount '{}'".format(amount))
|
||||
|
||||
if unit in units:
|
||||
return int(amount[:-1]) * 100_000_000_000 / units[unit]
|
||||
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
|
||||
else:
|
||||
return int(amount) * 100_000_000_000
|
||||
|
||||
|
||||
def pull_tagged(stream):
|
||||
def _pull_tagged(stream):
|
||||
tag = stream.read(5).uint
|
||||
length = stream.read(5).uint * 32 + stream.read(5).uint
|
||||
return (CHARSET[tag], stream.read(length * 5), stream)
|
||||
|
||||
|
||||
def trim_to_bytes(barr):
|
||||
def _trim_to_bytes(barr):
|
||||
# Adds a byte if necessary.
|
||||
b = barr.tobytes()
|
||||
if barr.len % 8 != 0:
|
||||
@@ -102,7 +157,15 @@ def trim_to_bytes(barr):
|
||||
return b
|
||||
|
||||
|
||||
def u5_to_bitarray(arr):
|
||||
def _readable_scid(short_channel_id: int) -> str:
|
||||
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
||||
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
|
||||
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
|
||||
outputindex=(short_channel_id & 0xFFFF),
|
||||
)
|
||||
|
||||
|
||||
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
|
||||
ret = bitstring.BitArray()
|
||||
for a in arr:
|
||||
ret += bitstring.pack("uint:5", a)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import warnings
|
||||
import click
|
||||
import importlib
|
||||
import re
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from .core import migrations as core_migrations
|
||||
from .db import open_db, open_ext_db
|
||||
from .helpers import get_valid_extensions, get_css_vendored, get_js_vendored, url_for_vendored
|
||||
from .settings import LNBITS_PATH
|
||||
|
||||
|
||||
@click.command("migrate")
|
||||
def db_migrate():
|
||||
migrate_databases()
|
||||
|
||||
|
||||
@click.command("assets")
|
||||
def handle_assets():
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
|
||||
def transpile_scss():
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
from scss.compiler import compile_string # type: ignore
|
||||
|
||||
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
|
||||
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
|
||||
css.write(compile_string(scss.read()))
|
||||
|
||||
|
||||
def bundle_vendored():
|
||||
for getfiles, outputpath in [
|
||||
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
|
||||
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
|
||||
]:
|
||||
output = ""
|
||||
for path in getfiles():
|
||||
with open(path) as f:
|
||||
output += "/* " + url_for_vendored(path) + " */\n" + f.read() + ";\n"
|
||||
with open(outputpath, "w") as f:
|
||||
f.write(output)
|
||||
|
||||
|
||||
def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
with open_db() as core_db:
|
||||
try:
|
||||
rows = core_db.fetchall("SELECT * FROM dbversions")
|
||||
except sqlite3.OperationalError:
|
||||
# migration 3 wasn't ran
|
||||
core_migrations.m000_create_migrations_table(core_db)
|
||||
rows = core_db.fetchall("SELECT * FROM dbversions")
|
||||
|
||||
current_versions = {row["db"]: row["version"] for row in rows}
|
||||
matcher = re.compile(r"^m(\d\d\d)_")
|
||||
|
||||
def run_migration(db, migrations_module):
|
||||
db_name = migrations_module.__name__.split(".")[-2]
|
||||
for key, run_migration in migrations_module.__dict__.items():
|
||||
match = match = matcher.match(key)
|
||||
if match:
|
||||
version = int(match.group(1))
|
||||
if version > current_versions.get(db_name, 0):
|
||||
print(f"running migration {db_name}.{version}")
|
||||
run_migration(db)
|
||||
core_db.execute(
|
||||
"INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)", (db_name, version)
|
||||
)
|
||||
|
||||
run_migration(core_db, core_migrations)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"lnbits.extensions.{ext.code}.migrations")
|
||||
with open_ext_db(ext.code) as db:
|
||||
run_migration(db, ext_migrations)
|
||||
except ImportError:
|
||||
raise ImportError(f"Please make sure that the extension `{ext.code}` has a migrations file.")
|
||||
@@ -1,9 +1,15 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
core_app: Blueprint = Blueprint("core", __name__, template_folder="templates", static_folder="static")
|
||||
core_app: Blueprint = Blueprint(
|
||||
"core", __name__, template_folder="templates", static_folder="static", static_url_path="/core/static"
|
||||
)
|
||||
|
||||
|
||||
from .views.api import * # noqa
|
||||
from .views.generic import * # noqa
|
||||
from .views.lnurl import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
core_app.record(record_async(register_listeners))
|
||||
|
||||
+221
-125
@@ -1,7 +1,10 @@
|
||||
from typing import List, Optional
|
||||
import json
|
||||
import datetime
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Dict
|
||||
from quart import g
|
||||
|
||||
from lnbits.db import open_db
|
||||
from lnbits import bolt11
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME
|
||||
|
||||
from .models import User, Wallet, Payment
|
||||
@@ -12,9 +15,8 @@ from .models import User, Wallet, Payment
|
||||
|
||||
|
||||
def create_account() -> User:
|
||||
with open_db() as db:
|
||||
user_id = uuid4().hex
|
||||
db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
|
||||
user_id = uuid4().hex
|
||||
g.db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
|
||||
|
||||
new_account = get_account(user_id=user_id)
|
||||
assert new_account, "Newly created account couldn't be retrieved"
|
||||
@@ -23,26 +25,24 @@ def create_account() -> User:
|
||||
|
||||
|
||||
def get_account(user_id: str) -> Optional[User]:
|
||||
with open_db() as db:
|
||||
row = db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,))
|
||||
row = g.db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,))
|
||||
|
||||
return User(**row) if row else None
|
||||
|
||||
|
||||
def get_user(user_id: str) -> Optional[User]:
|
||||
with open_db() as db:
|
||||
user = db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,))
|
||||
user = g.db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,))
|
||||
|
||||
if user:
|
||||
extensions = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,))
|
||||
wallets = db.fetchall(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE user = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
if user:
|
||||
extensions = g.db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,))
|
||||
wallets = g.db.fetchall(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE user = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
return (
|
||||
User(**{**user, **{"extensions": [e[0] for e in extensions], "wallets": [Wallet(**w) for w in wallets]}})
|
||||
@@ -52,14 +52,13 @@ def get_user(user_id: str) -> Optional[User]:
|
||||
|
||||
|
||||
def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
|
||||
with open_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO extensions (user, extension, active)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(user_id, extension, active),
|
||||
)
|
||||
g.db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO extensions (user, extension, active)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(user_id, extension, active),
|
||||
)
|
||||
|
||||
|
||||
# wallets
|
||||
@@ -67,15 +66,14 @@ def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
|
||||
|
||||
|
||||
def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet:
|
||||
with open_db() as db:
|
||||
wallet_id = uuid4().hex
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO wallets (id, name, user, adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(wallet_id, wallet_name or DEFAULT_WALLET_NAME, user_id, uuid4().hex, uuid4().hex),
|
||||
)
|
||||
wallet_id = uuid4().hex
|
||||
g.db.execute(
|
||||
"""
|
||||
INSERT INTO wallets (id, name, user, adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(wallet_id, wallet_name or DEFAULT_WALLET_NAME, user_id, uuid4().hex, uuid4().hex),
|
||||
)
|
||||
|
||||
new_wallet = get_wallet(wallet_id=wallet_id)
|
||||
assert new_wallet, "Newly created wallet couldn't be retrieved"
|
||||
@@ -84,100 +82,158 @@ def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet:
|
||||
|
||||
|
||||
def delete_wallet(*, user_id: str, wallet_id: str) -> None:
|
||||
with open_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE wallets AS w
|
||||
SET
|
||||
user = 'del:' || w.user,
|
||||
adminkey = 'del:' || w.adminkey,
|
||||
inkey = 'del:' || w.inkey
|
||||
WHERE id = ? AND user = ?
|
||||
""",
|
||||
(wallet_id, user_id),
|
||||
)
|
||||
g.db.execute(
|
||||
"""
|
||||
UPDATE wallets AS w
|
||||
SET
|
||||
user = 'del:' || w.user,
|
||||
adminkey = 'del:' || w.adminkey,
|
||||
inkey = 'del:' || w.inkey
|
||||
WHERE id = ? AND user = ?
|
||||
""",
|
||||
(wallet_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
def get_wallet(wallet_id: str) -> Optional[Wallet]:
|
||||
with open_db() as db:
|
||||
row = db.fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE id = ?
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
row = g.db.fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE id = ?
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
|
||||
return Wallet(**row) if row else None
|
||||
|
||||
|
||||
def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
|
||||
with open_db() as db:
|
||||
row = db.fetchone(
|
||||
f"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE adminkey = ? OR inkey = ?
|
||||
""",
|
||||
(key, key),
|
||||
)
|
||||
row = g.db.fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE adminkey = ? OR inkey = ?
|
||||
""",
|
||||
(key, key),
|
||||
)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
|
||||
if key_type == "admin" and row["adminkey"] != key:
|
||||
return None
|
||||
if key_type == "admin" and row["adminkey"] != key:
|
||||
return None
|
||||
|
||||
return Wallet(**row)
|
||||
return Wallet(**row)
|
||||
|
||||
|
||||
# wallet payments
|
||||
# ---------------
|
||||
|
||||
|
||||
def get_wallet_payment(wallet_id: str, checking_id: str) -> Optional[Payment]:
|
||||
with open_db() as db:
|
||||
row = db.fetchone(
|
||||
def get_standalone_payment(checking_id: str) -> Optional[Payment]:
|
||||
row = g.db.fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE checking_id = ?
|
||||
""",
|
||||
(checking_id,),
|
||||
)
|
||||
|
||||
return Payment.from_row(row) if row else None
|
||||
|
||||
|
||||
def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
|
||||
row = g.db.fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet = ? AND hash = ?
|
||||
""",
|
||||
(wallet_id, payment_hash),
|
||||
)
|
||||
|
||||
return Payment.from_row(row) if row else None
|
||||
|
||||
|
||||
def get_wallet_payments(
|
||||
wallet_id: str,
|
||||
*,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
outgoing: bool = False,
|
||||
incoming: bool = False,
|
||||
exclude_uncheckable: bool = False,
|
||||
) -> List[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||
"""
|
||||
|
||||
clause = ""
|
||||
if complete and pending:
|
||||
clause += ""
|
||||
elif complete:
|
||||
clause += "AND ((amount > 0 AND pending = 0) OR amount < 0)"
|
||||
elif pending:
|
||||
clause += "AND pending = 1"
|
||||
else:
|
||||
raise TypeError("at least one of [complete, pending] must be True.")
|
||||
|
||||
clause += " "
|
||||
|
||||
if outgoing and incoming:
|
||||
clause += ""
|
||||
elif outgoing:
|
||||
clause += "AND amount < 0"
|
||||
elif incoming:
|
||||
clause += "AND amount > 0"
|
||||
else:
|
||||
raise TypeError("at least one of [outgoing, incoming] must be True.")
|
||||
|
||||
clause += " "
|
||||
|
||||
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
|
||||
clause += "AND checking_id NOT LIKE 'temp_%' "
|
||||
clause += "AND checking_id NOT LIKE 'internal_%' "
|
||||
|
||||
rows = g.db.fetchall(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet = ? {clause}
|
||||
ORDER BY time DESC
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
|
||||
return [Payment.from_row(row) for row in rows]
|
||||
|
||||
|
||||
def delete_expired_invoices() -> None:
|
||||
rows = g.db.fetchall(
|
||||
"""
|
||||
SELECT bolt11
|
||||
FROM apipayments
|
||||
WHERE pending = 1 AND amount > 0 AND time < strftime('%s', 'now') - 86400
|
||||
"""
|
||||
)
|
||||
for (payment_request,) in rows:
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
except:
|
||||
continue
|
||||
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration_date > datetime.datetime.utcnow():
|
||||
continue
|
||||
|
||||
g.db.execute(
|
||||
"""
|
||||
SELECT payhash as checking_id, amount, fee, pending, memo, time
|
||||
FROM apipayments
|
||||
WHERE wallet = ? AND payhash = ?
|
||||
DELETE FROM apipayments
|
||||
WHERE pending = 1 AND hash = ?
|
||||
""",
|
||||
(wallet_id, checking_id),
|
||||
)
|
||||
|
||||
return Payment(**row) if row else None
|
||||
|
||||
|
||||
def get_wallet_payments(wallet_id: str, *, include_all_pending: bool = False) -> List[Payment]:
|
||||
with open_db() as db:
|
||||
if include_all_pending:
|
||||
clause = "pending = 1"
|
||||
else:
|
||||
clause = "((amount > 0 AND pending = 0) OR amount < 0)"
|
||||
|
||||
rows = db.fetchall(
|
||||
f"""
|
||||
SELECT payhash as checking_id, amount, fee, pending, memo, time
|
||||
FROM apipayments
|
||||
WHERE wallet = ? AND {clause}
|
||||
ORDER BY time DESC
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
|
||||
return [Payment(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_wallet_payments_expired(wallet_id: str, *, seconds: int = 86400) -> None:
|
||||
with open_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
DELETE
|
||||
FROM apipayments WHERE wallet = ? AND pending = 1 AND time < strftime('%s', 'now') - ?
|
||||
""",
|
||||
(wallet_id, seconds),
|
||||
(invoice.payment_hash,),
|
||||
)
|
||||
|
||||
|
||||
@@ -186,28 +242,68 @@ def delete_wallet_payments_expired(wallet_id: str, *, seconds: int = 86400) -> N
|
||||
|
||||
|
||||
def create_payment(
|
||||
*, wallet_id: str, checking_id: str, amount: int, memo: str, fee: int = 0, pending: bool = True
|
||||
*,
|
||||
wallet_id: str,
|
||||
checking_id: str,
|
||||
payment_request: str,
|
||||
payment_hash: str,
|
||||
amount: int,
|
||||
memo: str,
|
||||
fee: int = 0,
|
||||
preimage: Optional[str] = None,
|
||||
pending: bool = True,
|
||||
extra: Optional[Dict] = None,
|
||||
) -> Payment:
|
||||
with open_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO apipayments (wallet, payhash, amount, pending, memo, fee)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(wallet_id, checking_id, amount, int(pending), memo, fee),
|
||||
)
|
||||
g.db.execute(
|
||||
"""
|
||||
INSERT INTO apipayments
|
||||
(wallet, checking_id, bolt11, hash, preimage,
|
||||
amount, pending, memo, fee, extra)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
checking_id,
|
||||
payment_request,
|
||||
payment_hash,
|
||||
preimage,
|
||||
amount,
|
||||
int(pending),
|
||||
memo,
|
||||
fee,
|
||||
json.dumps(extra) if extra and extra != {} and type(extra) is dict else None,
|
||||
),
|
||||
)
|
||||
|
||||
new_payment = get_wallet_payment(wallet_id, checking_id)
|
||||
new_payment = get_wallet_payment(wallet_id, payment_hash)
|
||||
assert new_payment, "Newly created payment couldn't be retrieved"
|
||||
|
||||
return new_payment
|
||||
|
||||
|
||||
def update_payment_status(checking_id: str, pending: bool) -> None:
|
||||
with open_db() as db:
|
||||
db.execute("UPDATE apipayments SET pending = ? WHERE payhash = ?", (int(pending), checking_id,))
|
||||
g.db.execute(
|
||||
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
|
||||
(
|
||||
int(pending),
|
||||
checking_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def delete_payment(checking_id: str) -> None:
|
||||
with open_db() as db:
|
||||
db.execute("DELETE FROM apipayments WHERE payhash = ?", (checking_id,))
|
||||
g.db.execute("DELETE FROM apipayments WHERE checking_id = ?", (checking_id,))
|
||||
|
||||
|
||||
def check_internal(payment_hash: str) -> Optional[str]:
|
||||
row = g.db.fetchone(
|
||||
"""
|
||||
SELECT checking_id FROM apipayments
|
||||
WHERE hash = ? AND pending AND amount > 0
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
else:
|
||||
return row["checking_id"]
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
from lnbits.db import open_db
|
||||
import sqlite3
|
||||
|
||||
|
||||
def m000_create_migrations_table(db):
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE dbversions (
|
||||
db TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def m001_initial(db):
|
||||
@@ -51,6 +62,7 @@ def m001_initial(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE VIEW IF NOT EXISTS balances AS
|
||||
@@ -70,6 +82,41 @@ def m001_initial(db):
|
||||
)
|
||||
|
||||
|
||||
def migrate():
|
||||
with open_db() as db:
|
||||
m001_initial(db)
|
||||
def m002_add_fields_to_apipayments(db):
|
||||
"""
|
||||
Adding fields to apipayments for better accounting,
|
||||
and renaming payhash to checking_id since that is what it really is.
|
||||
"""
|
||||
try:
|
||||
db.execute("ALTER TABLE apipayments RENAME COLUMN payhash TO checking_id")
|
||||
db.execute("ALTER TABLE apipayments ADD COLUMN hash TEXT")
|
||||
db.execute("CREATE INDEX by_hash ON apipayments (hash)")
|
||||
db.execute("ALTER TABLE apipayments ADD COLUMN preimage TEXT")
|
||||
db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
|
||||
db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
|
||||
|
||||
import json
|
||||
|
||||
rows = db.fetchall("SELECT * FROM apipayments")
|
||||
for row in rows:
|
||||
if not row["memo"] or not row["memo"].startswith("#"):
|
||||
continue
|
||||
|
||||
for ext in ["withdraw", "events", "lnticket", "paywall", "tpos"]:
|
||||
prefix = "#" + ext + " "
|
||||
if row["memo"].startswith(prefix):
|
||||
new = row["memo"][len(prefix) :]
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?, memo = ?
|
||||
WHERE checking_id = ? AND memo = ?
|
||||
""",
|
||||
(json.dumps({"tag": ext}), new, row["checking_id"], row["memo"]),
|
||||
)
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
# run twice in some environments.
|
||||
# catching errors like this won't be necessary in anymore now that we
|
||||
# keep track of db versions so no migration ever runs twice.
|
||||
pass
|
||||
|
||||
+78
-11
@@ -1,4 +1,11 @@
|
||||
from typing import List, NamedTuple, Optional
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
from ecdsa import SECP256k1, SigningKey # type: ignore
|
||||
from typing import List, NamedTuple, Optional, Dict
|
||||
from sqlite3 import Row
|
||||
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
|
||||
class User(NamedTuple):
|
||||
@@ -27,22 +34,42 @@ class Wallet(NamedTuple):
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
return self.balance // 1000
|
||||
return self.balance_msat // 1000
|
||||
|
||||
def get_payment(self, checking_id: str) -> Optional["Payment"]:
|
||||
def lnurlauth_key(self, domain: str) -> SigningKey:
|
||||
hashing_key = hashlib.sha256(self.id.encode("utf-8")).digest()
|
||||
linking_key = hmac.digest(hashing_key, domain.encode("utf-8"), "sha256")
|
||||
|
||||
return SigningKey.from_string(
|
||||
linking_key,
|
||||
curve=SECP256k1,
|
||||
hashfunc=hashlib.sha256,
|
||||
)
|
||||
|
||||
def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||
from .crud import get_wallet_payment
|
||||
|
||||
return get_wallet_payment(self.id, checking_id)
|
||||
return get_wallet_payment(self.id, payment_hash)
|
||||
|
||||
def get_payments(self, *, include_all_pending: bool = False) -> List["Payment"]:
|
||||
def get_payments(
|
||||
self,
|
||||
*,
|
||||
complete: bool = True,
|
||||
pending: bool = False,
|
||||
outgoing: bool = True,
|
||||
incoming: bool = True,
|
||||
exclude_uncheckable: bool = False
|
||||
) -> List["Payment"]:
|
||||
from .crud import get_wallet_payments
|
||||
|
||||
return get_wallet_payments(self.id, include_all_pending=include_all_pending)
|
||||
|
||||
def delete_expired_payments(self, seconds: int = 86400) -> None:
|
||||
from .crud import delete_wallet_payments_expired
|
||||
|
||||
delete_wallet_payments_expired(self.id, seconds=seconds)
|
||||
return get_wallet_payments(
|
||||
self.id,
|
||||
complete=complete,
|
||||
pending=pending,
|
||||
outgoing=outgoing,
|
||||
incoming=incoming,
|
||||
exclude_uncheckable=exclude_uncheckable,
|
||||
)
|
||||
|
||||
|
||||
class Payment(NamedTuple):
|
||||
@@ -52,6 +79,31 @@ class Payment(NamedTuple):
|
||||
fee: int
|
||||
memo: str
|
||||
time: int
|
||||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
extra: Dict
|
||||
wallet_id: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(
|
||||
checking_id=row["checking_id"],
|
||||
payment_hash=row["hash"] or "0" * 64,
|
||||
bolt11=row["bolt11"] or "",
|
||||
preimage=row["preimage"] or "0" * 64,
|
||||
extra=json.loads(row["extra"] or "{}"),
|
||||
pending=row["pending"],
|
||||
amount=row["amount"],
|
||||
fee=row["fee"],
|
||||
memo=row["memo"],
|
||||
time=row["time"],
|
||||
wallet_id=row["wallet"],
|
||||
)
|
||||
|
||||
@property
|
||||
def tag(self) -> Optional[str]:
|
||||
return self.extra.get("tag")
|
||||
|
||||
@property
|
||||
def msat(self) -> int:
|
||||
@@ -69,11 +121,26 @@ class Payment(NamedTuple):
|
||||
def is_out(self) -> bool:
|
||||
return self.amount < 0
|
||||
|
||||
@property
|
||||
def is_uncheckable(self) -> bool:
|
||||
return self.checking_id.startswith("temp_") or self.checking_id.startswith("internal_")
|
||||
|
||||
def set_pending(self, pending: bool) -> None:
|
||||
from .crud import update_payment_status
|
||||
|
||||
update_payment_status(self.checking_id, pending)
|
||||
|
||||
def check_pending(self) -> None:
|
||||
if self.is_uncheckable:
|
||||
return
|
||||
|
||||
if self.is_out:
|
||||
pending = WALLET.get_payment_status(self.checking_id)
|
||||
else:
|
||||
pending = WALLET.get_invoice_status(self.checking_id)
|
||||
|
||||
self.set_pending(pending.pending)
|
||||
|
||||
def delete(self) -> None:
|
||||
from .crud import delete_payment
|
||||
|
||||
|
||||
+211
-46
@@ -1,73 +1,238 @@
|
||||
from typing import Optional, Tuple
|
||||
import trio # type: ignore
|
||||
import json
|
||||
import httpx
|
||||
from io import BytesIO
|
||||
from binascii import unhexlify
|
||||
from typing import Optional, Tuple, Dict
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from quart import g
|
||||
from lnurl import LnurlErrorResponse, LnurlWithdrawResponse # type: ignore
|
||||
|
||||
from lnbits.bolt11 import decode as bolt11_decode # type: ignore
|
||||
try:
|
||||
from typing import TypedDict # type: ignore
|
||||
except ImportError: # pragma: nocover
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import WALLET
|
||||
from lnbits.wallets.base import PaymentStatus, PaymentResponse
|
||||
|
||||
from .crud import get_wallet, create_payment, delete_payment
|
||||
from .crud import get_wallet, create_payment, delete_payment, check_internal, update_payment_status, get_wallet_payment
|
||||
|
||||
|
||||
def create_invoice(*, wallet_id: str, amount: int, memo: str) -> Tuple[str, str]:
|
||||
try:
|
||||
ok, checking_id, payment_request, error_message = WALLET.create_invoice(amount=amount, memo=memo)
|
||||
except Exception as e:
|
||||
ok, error_message = False, str(e)
|
||||
def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
amount: int, # in satoshis
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
) -> Tuple[str, str]:
|
||||
invoice_memo = None if description_hash else memo
|
||||
storeable_memo = memo
|
||||
|
||||
ok, checking_id, payment_request, error_message = WALLET.create_invoice(
|
||||
amount=amount, memo=invoice_memo, description_hash=description_hash
|
||||
)
|
||||
if not ok:
|
||||
raise Exception(error_message or "Unexpected backend error.")
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
amount_msat = amount * 1000
|
||||
create_payment(wallet_id=wallet_id, checking_id=checking_id, amount=amount_msat, memo=memo)
|
||||
create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=amount_msat,
|
||||
memo=storeable_memo,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
return checking_id, payment_request
|
||||
g.db.commit()
|
||||
return invoice.payment_hash, payment_request
|
||||
|
||||
|
||||
def pay_invoice(*, wallet_id: str, bolt11: str, max_sat: Optional[int] = None) -> str:
|
||||
def pay_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
payment_request: str,
|
||||
max_sat: Optional[int] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
description: str = "",
|
||||
) -> str:
|
||||
temp_id = f"temp_{urlsafe_short_hash()}"
|
||||
try:
|
||||
invoice = bolt11_decode(bolt11)
|
||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||
|
||||
if invoice.amount_msat == 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
invoice = bolt11.decode(payment_request)
|
||||
if invoice.amount_msat == 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
# put all parameters that don't change here
|
||||
PaymentKwargs = TypedDict(
|
||||
"PaymentKwargs",
|
||||
{
|
||||
"wallet_id": str,
|
||||
"payment_request": str,
|
||||
"payment_hash": str,
|
||||
"amount": int,
|
||||
"memo": str,
|
||||
"extra": Optional[Dict],
|
||||
},
|
||||
)
|
||||
payment_kwargs: PaymentKwargs = dict(
|
||||
wallet_id=wallet_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=-invoice.amount_msat,
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||
internal_checking_id = check_internal(invoice.payment_hash)
|
||||
if internal_checking_id:
|
||||
# create a new payment from this wallet
|
||||
create_payment(checking_id=internal_id, fee=0, pending=False, **payment_kwargs)
|
||||
else:
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
|
||||
create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=temp_id,
|
||||
amount=-invoice.amount_msat,
|
||||
fee=-fee_reserve,
|
||||
memo=temp_id,
|
||||
create_payment(checking_id=temp_id, fee=-fee_reserve, **payment_kwargs)
|
||||
|
||||
# do the balance check
|
||||
wallet = get_wallet(wallet_id)
|
||||
assert wallet
|
||||
if wallet.balance_msat < 0:
|
||||
g.db.rollback()
|
||||
raise PermissionError("Insufficient balance.")
|
||||
else:
|
||||
g.db.commit()
|
||||
|
||||
if internal_checking_id:
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
# so the other side only has access to his new money when we are sure
|
||||
# the payer has enough to deduct from
|
||||
update_payment_status(checking_id=internal_checking_id, pending=False)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_paid
|
||||
|
||||
try:
|
||||
internal_invoice_paid.send_nowait(internal_checking_id)
|
||||
except trio.WouldBlock:
|
||||
pass
|
||||
else:
|
||||
# actually pay the external invoice
|
||||
payment: PaymentResponse = WALLET.pay_invoice(payment_request)
|
||||
if payment.ok and payment.checking_id:
|
||||
create_payment(
|
||||
checking_id=payment.checking_id,
|
||||
fee=payment.fee_msat,
|
||||
preimage=payment.preimage,
|
||||
**payment_kwargs,
|
||||
)
|
||||
delete_payment(temp_id)
|
||||
else:
|
||||
raise Exception(payment.error_message or "Failed to pay_invoice on backend.")
|
||||
|
||||
g.db.commit()
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
async def redeem_lnurl_withdraw(wallet_id: str, res: LnurlWithdrawResponse, memo: Optional[str] = None) -> None:
|
||||
_, payment_request = create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=res.max_sats,
|
||||
memo=memo or res.default_description or "",
|
||||
extra={"tag": "lnurlwallet"},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.get(
|
||||
res.callback.base,
|
||||
params={**res.callback.query_params, **{"k1": res.k1, "pr": payment_request}},
|
||||
)
|
||||
|
||||
wallet = get_wallet(wallet_id)
|
||||
assert wallet, "invalid wallet id"
|
||||
if wallet.balance_msat < 0:
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
ok, checking_id, fee_msat, error_message = WALLET.pay_invoice(bolt11)
|
||||
async def perform_lnurlauth(callback: str) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
if ok:
|
||||
create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=checking_id,
|
||||
amount=-invoice.amount_msat,
|
||||
fee=fee_msat,
|
||||
memo=invoice.description,
|
||||
k1 = unhexlify(parse_qs(cb.query)["k1"][0])
|
||||
key = g.wallet.lnurlauth_key(cb.netloc)
|
||||
|
||||
def int_to_bytes_suitable_der(x: int) -> bytes:
|
||||
"""for strict DER we need to encode the integer with some quirks"""
|
||||
b = x.to_bytes((x.bit_length() + 7) // 8, "big")
|
||||
|
||||
if len(b) == 0:
|
||||
# ensure there's at least one byte when the int is zero
|
||||
return bytes([0])
|
||||
|
||||
if b[0] & 0x80 != 0:
|
||||
# ensure it doesn't start with a 0x80 and so it isn't
|
||||
# interpreted as a negative number
|
||||
return bytes([0]) + b
|
||||
|
||||
return b
|
||||
|
||||
def encode_strict_der(r_int, s_int, order):
|
||||
# if s > order/2 verification will fail sometimes
|
||||
# so we must fix it here (see https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147)
|
||||
if s_int > order // 2:
|
||||
s_int = order - s_int
|
||||
|
||||
# now we do the strict DER encoding copied from
|
||||
# https://github.com/KiriKiri/bip66 (without any checks)
|
||||
r = int_to_bytes_suitable_der(r_int)
|
||||
s = int_to_bytes_suitable_der(s_int)
|
||||
|
||||
r_len = len(r)
|
||||
s_len = len(s)
|
||||
sign_len = 6 + r_len + s_len
|
||||
|
||||
signature = BytesIO()
|
||||
signature.write(0x30 .to_bytes(1, "big", signed=False))
|
||||
signature.write((sign_len - 2).to_bytes(1, "big", signed=False))
|
||||
signature.write(0x02 .to_bytes(1, "big", signed=False))
|
||||
signature.write(r_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(r)
|
||||
signature.write(0x02 .to_bytes(1, "big", signed=False))
|
||||
signature.write(s_len.to_bytes(1, "big", signed=False))
|
||||
signature.write(s)
|
||||
|
||||
return signature.getvalue()
|
||||
|
||||
sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
callback,
|
||||
params={
|
||||
"k1": k1.hex(),
|
||||
"key": key.verifying_key.to_string("compressed").hex(),
|
||||
"sig": sig.hex(),
|
||||
},
|
||||
)
|
||||
try:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] == "OK":
|
||||
return None
|
||||
|
||||
return LnurlErrorResponse(reason=resp["reason"])
|
||||
except (KeyError, json.decoder.JSONDecodeError):
|
||||
return LnurlErrorResponse(
|
||||
reason=r.text[:200] + "..." if len(r.text) > 200 else r.text,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
ok, error_message = False, str(e)
|
||||
|
||||
delete_payment(temp_id)
|
||||
def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
|
||||
payment = get_wallet_payment(wallet_id, payment_hash)
|
||||
if not payment:
|
||||
return PaymentStatus(None)
|
||||
|
||||
if not ok:
|
||||
raise Exception(error_message or "Unexpected backend error.")
|
||||
|
||||
return checking_id
|
||||
|
||||
|
||||
def check_payment(*, checking_id: str) -> str:
|
||||
pass
|
||||
return WALLET.get_invoice_status(payment.checking_id)
|
||||
|
||||
+357
-124
@@ -1,3 +1,5 @@
|
||||
/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart, decryptLnurlPayAES */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
@@ -12,10 +14,8 @@ function generateChart(canvas, payments) {
|
||||
}
|
||||
|
||||
_.each(
|
||||
payments.slice(0).sort(function (a, b) {
|
||||
return a.time - b.time
|
||||
}),
|
||||
function (tx) {
|
||||
payments.filter(p => !p.pending).sort((a, b) => a.time - b.time),
|
||||
tx => {
|
||||
txs.push({
|
||||
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
|
||||
sat: tx.sat
|
||||
@@ -23,19 +23,15 @@ function generateChart(canvas, payments) {
|
||||
}
|
||||
)
|
||||
|
||||
_.each(_.groupBy(txs, 'hour'), function (value, day) {
|
||||
_.each(_.groupBy(txs, 'hour'), (value, day) => {
|
||||
var income = _.reduce(
|
||||
value,
|
||||
function (memo, tx) {
|
||||
return tx.sat >= 0 ? memo + tx.sat : memo
|
||||
},
|
||||
(memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
|
||||
0
|
||||
)
|
||||
var outcome = _.reduce(
|
||||
value,
|
||||
function (memo, tx) {
|
||||
return tx.sat < 0 ? memo + Math.abs(tx.sat) : memo
|
||||
},
|
||||
(memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
|
||||
0
|
||||
)
|
||||
n = n + income - outcome
|
||||
@@ -115,30 +111,44 @@ new Vue({
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
user: LNbits.map.user(window.user),
|
||||
receive: {
|
||||
show: false,
|
||||
status: 'pending',
|
||||
paymentReq: null,
|
||||
paymentHash: null,
|
||||
minMax: [0, 2100000000000000],
|
||||
lnurl: null,
|
||||
data: {
|
||||
amount: null,
|
||||
memo: ''
|
||||
}
|
||||
},
|
||||
send: {
|
||||
parse: {
|
||||
show: false,
|
||||
invoice: null,
|
||||
lnurlpay: null,
|
||||
lnurlauth: null,
|
||||
data: {
|
||||
bolt11: ''
|
||||
request: '',
|
||||
amount: 0,
|
||||
comment: ''
|
||||
},
|
||||
paymentChecker: null,
|
||||
camera: {
|
||||
show: false,
|
||||
camera: 'auto'
|
||||
}
|
||||
},
|
||||
sendCamera: {
|
||||
show: false,
|
||||
camera: 'auto'
|
||||
},
|
||||
payments: [],
|
||||
paymentsTable: {
|
||||
columns: [
|
||||
{name: 'memo', align: 'left', label: 'Memo', field: 'memo'},
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: 'Memo',
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
@@ -152,6 +162,12 @@ new Vue({
|
||||
label: 'Amount (sat)',
|
||||
field: 'sat',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'right',
|
||||
label: 'Fee (msat)',
|
||||
field: 'fee'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
@@ -165,32 +181,23 @@ new Vue({
|
||||
disclaimerDialog: {
|
||||
show: false,
|
||||
location: window.location
|
||||
}
|
||||
},
|
||||
balance: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formattedBalance: function () {
|
||||
return LNbits.utils.formatSat(this.balance || this.g.wallet.sat)
|
||||
},
|
||||
filteredPayments: function () {
|
||||
var q = this.paymentsTable.filter
|
||||
if (!q || q == '') return this.payments
|
||||
if (!q || q === '') return this.payments
|
||||
|
||||
return LNbits.utils.search(this.payments, q)
|
||||
},
|
||||
balance: function () {
|
||||
if (this.payments.length) {
|
||||
return (
|
||||
_.pluck(this.payments, 'amount').reduce(function (a, b) {
|
||||
return a + b
|
||||
}, 0) / 1000
|
||||
)
|
||||
}
|
||||
return this.g.wallet.sat
|
||||
},
|
||||
fbalance: function () {
|
||||
return LNbits.utils.formatSat(this.balance)
|
||||
},
|
||||
canPay: function () {
|
||||
if (!this.send.invoice) return false
|
||||
return this.send.invoice.sat <= this.balance
|
||||
if (!this.parse.invoice) return false
|
||||
return this.parse.invoice.sat <= this.balance
|
||||
},
|
||||
pendingPaymentsExist: function () {
|
||||
return this.payments
|
||||
@@ -198,105 +205,201 @@ new Vue({
|
||||
: false
|
||||
}
|
||||
},
|
||||
filters: {
|
||||
msatoshiFormat: function (value) {
|
||||
return LNbits.utils.formatSat(value / 1000)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeCamera: function () {
|
||||
this.sendCamera.show = false
|
||||
this.parse.camera.show = false
|
||||
},
|
||||
showCamera: function () {
|
||||
this.sendCamera.show = true
|
||||
this.parse.camera.show = true
|
||||
},
|
||||
showChart: function () {
|
||||
this.paymentsChart.show = true
|
||||
this.$nextTick(function () {
|
||||
this.$nextTick(() => {
|
||||
generateChart(this.$refs.canvas, this.payments)
|
||||
})
|
||||
},
|
||||
showReceiveDialog: function () {
|
||||
this.receive = {
|
||||
show: true,
|
||||
status: 'pending',
|
||||
paymentReq: null,
|
||||
data: {
|
||||
amount: null,
|
||||
memo: ''
|
||||
},
|
||||
paymentChecker: null
|
||||
}
|
||||
this.receive.show = true
|
||||
this.receive.status = 'pending'
|
||||
this.receive.paymentReq = null
|
||||
this.receive.paymentHash = null
|
||||
this.receive.data.amount = null
|
||||
this.receive.data.memo = null
|
||||
this.receive.paymentChecker = null
|
||||
this.receive.minMax = [0, 2100000000000000]
|
||||
this.receive.lnurl = null
|
||||
},
|
||||
showSendDialog: function () {
|
||||
this.send = {
|
||||
show: true,
|
||||
invoice: null,
|
||||
data: {
|
||||
bolt11: ''
|
||||
},
|
||||
paymentChecker: null
|
||||
}
|
||||
showParseDialog: function () {
|
||||
this.parse.show = true
|
||||
this.parse.invoice = null
|
||||
this.parse.lnurlpay = null
|
||||
this.parse.lnurlauth = null
|
||||
this.parse.data.request = ''
|
||||
this.parse.data.comment = ''
|
||||
this.parse.data.paymentChecker = null
|
||||
this.parse.camera.show = false
|
||||
},
|
||||
closeReceiveDialog: function () {
|
||||
var checker = this.receive.paymentChecker
|
||||
setTimeout(function () {
|
||||
clearInterval(checker)
|
||||
setTimeout(() => {
|
||||
clearInterval(this.receive.paymentChecker)
|
||||
}, 10000)
|
||||
},
|
||||
closeSendDialog: function () {
|
||||
this.sendCamera.show = false
|
||||
var checker = this.send.paymentChecker
|
||||
setTimeout(function () {
|
||||
clearInterval(checker)
|
||||
}, 1000)
|
||||
closeParseDialog: function () {
|
||||
setTimeout(() => {
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
}, 10000)
|
||||
},
|
||||
onPaymentReceived: function (paymentHash) {
|
||||
this.fetchPayments()
|
||||
this.fetchBalance()
|
||||
|
||||
if (this.receive.paymentHash === paymentHash) {
|
||||
this.receive.show = false
|
||||
this.receive.paymentHash = null
|
||||
clearInterval(this.receive.paymentChecker)
|
||||
}
|
||||
},
|
||||
createInvoice: function () {
|
||||
var self = this
|
||||
this.receive.status = 'loading'
|
||||
LNbits.api
|
||||
.createInvoice(
|
||||
this.g.wallet,
|
||||
this.receive.data.amount,
|
||||
this.receive.data.memo
|
||||
this.receive.data.memo,
|
||||
this.receive.lnurl && this.receive.lnurl.callback
|
||||
)
|
||||
.then(function (response) {
|
||||
self.receive.status = 'success'
|
||||
self.receive.paymentReq = response.data.payment_request
|
||||
.then(response => {
|
||||
this.receive.status = 'success'
|
||||
this.receive.paymentReq = response.data.payment_request
|
||||
this.receive.paymentHash = response.data.payment_hash
|
||||
|
||||
self.receive.paymentChecker = setInterval(function () {
|
||||
LNbits.api
|
||||
.getPayment(self.g.wallet, response.data.checking_id)
|
||||
.then(function (response) {
|
||||
if (response.data.paid) {
|
||||
self.fetchPayments()
|
||||
self.receive.show = false
|
||||
clearInterval(self.receive.paymentChecker)
|
||||
}
|
||||
if (response.data.lnurl_response !== null) {
|
||||
if (response.data.lnurl_response === false) {
|
||||
response.data.lnurl_response = `Unable to connect`
|
||||
}
|
||||
|
||||
if (typeof response.data.lnurl_response === 'string') {
|
||||
// failure
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: `${this.receive.lnurl.domain} lnurl-withdraw call failed.`,
|
||||
caption: response.data.lnurl_response
|
||||
})
|
||||
}, 2000)
|
||||
return
|
||||
} else if (response.data.lnurl_response === true) {
|
||||
// success
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
message: `Invoice sent to ${this.receive.lnurl.domain}!`,
|
||||
spinner: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
clearInterval(this.receive.paymentChecker)
|
||||
setTimeout(() => {
|
||||
clearInterval(this.receive.paymentChecker)
|
||||
}, 40000)
|
||||
this.receive.paymentChecker = setInterval(() => {
|
||||
let hash = response.data.payment_hash
|
||||
|
||||
LNbits.api.getPayment(this.g.wallet, hash).then(response => {
|
||||
if (response.data.paid) {
|
||||
this.onPaymentReceived(hash)
|
||||
}
|
||||
})
|
||||
}, 5000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
self.receive.status = 'pending'
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
this.receive.status = 'pending'
|
||||
})
|
||||
},
|
||||
decodeQR: function (res) {
|
||||
this.send.data.bolt11 = res
|
||||
this.decodeInvoice()
|
||||
this.sendCamera.show = false
|
||||
this.parse.data.request = res
|
||||
this.decodeRequest()
|
||||
this.parse.camera.show = false
|
||||
},
|
||||
decodeInvoice: function () {
|
||||
if (this.send.data.bolt11.startsWith('lightning:')) {
|
||||
this.send.data.bolt11 = this.send.data.bolt11.slice(10)
|
||||
decodeRequest: function () {
|
||||
this.parse.show = true
|
||||
|
||||
if (this.parse.data.request.startsWith('lightning:')) {
|
||||
this.parse.data.request = this.parse.data.request.slice(10)
|
||||
} else if (this.parse.data.request.startsWith('lnurl:')) {
|
||||
this.parse.data.request = this.parse.data.request.slice(6)
|
||||
} else if (this.parse.data.request.indexOf('lightning=lnurl1') !== -1) {
|
||||
this.parse.data.request = this.parse.data.request
|
||||
.split('lightning=')[1]
|
||||
.split('&')[0]
|
||||
}
|
||||
|
||||
if (this.parse.data.request.toLowerCase().startsWith('lnurl1')) {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/api/v1/lnurlscan/' + this.parse.data.request,
|
||||
this.g.wallet.adminkey
|
||||
)
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
.then(response => {
|
||||
let data = response.data
|
||||
|
||||
if (data.status === 'ERROR') {
|
||||
this.$q.notify({
|
||||
timeout: 5000,
|
||||
type: 'warning',
|
||||
message: `${data.domain} lnurl call failed.`,
|
||||
caption: data.reason
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (data.kind === 'pay') {
|
||||
this.parse.lnurlpay = Object.freeze(data)
|
||||
this.parse.data.amount = data.minSendable / 1000
|
||||
} else if (data.kind === 'auth') {
|
||||
this.parse.lnurlauth = Object.freeze(data)
|
||||
} else if (data.kind === 'withdraw') {
|
||||
this.parse.show = false
|
||||
this.receive.show = true
|
||||
this.receive.status = 'pending'
|
||||
this.receive.paymentReq = null
|
||||
this.receive.paymentHash = null
|
||||
this.receive.data.amount = data.maxWithdrawable / 1000
|
||||
this.receive.data.memo = data.defaultDescription
|
||||
this.receive.minMax = [
|
||||
data.minWithdrawable / 1000,
|
||||
data.maxWithdrawable / 1000
|
||||
]
|
||||
this.receive.lnurl = {
|
||||
domain: data.domain,
|
||||
callback: data.callback,
|
||||
fixed: data.fixed
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let invoice
|
||||
try {
|
||||
invoice = decode(this.send.data.bolt11)
|
||||
invoice = decode(this.parse.data.request)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
timeout: 3000,
|
||||
type: 'warning',
|
||||
message: error + '.',
|
||||
caption: '400 BAD REQUEST',
|
||||
icon: null
|
||||
caption: '400 BAD REQUEST'
|
||||
})
|
||||
this.parse.show = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -306,13 +409,13 @@ new Vue({
|
||||
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000)
|
||||
}
|
||||
|
||||
_.each(invoice.data.tags, function (tag) {
|
||||
_.each(invoice.data.tags, tag => {
|
||||
if (_.isObject(tag) && _.has(tag, 'description')) {
|
||||
if (tag.description == 'payment_hash') {
|
||||
if (tag.description === 'payment_hash') {
|
||||
cleanInvoice.hash = tag.value
|
||||
} else if (tag.description == 'description') {
|
||||
} else if (tag.description === 'description') {
|
||||
cleanInvoice.description = tag.value
|
||||
} else if (tag.description == 'expiry') {
|
||||
} else if (tag.description === 'expiry') {
|
||||
var expireDate = new Date(
|
||||
(invoice.data.time_stamp + tag.value) * 1000
|
||||
)
|
||||
@@ -325,68 +428,191 @@ new Vue({
|
||||
}
|
||||
})
|
||||
|
||||
this.send.invoice = Object.freeze(cleanInvoice)
|
||||
this.parse.invoice = Object.freeze(cleanInvoice)
|
||||
},
|
||||
payInvoice: function () {
|
||||
var self = this
|
||||
|
||||
dismissPaymentMsg = this.$q.notify({
|
||||
let dismissPaymentMsg = this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Processing payment...',
|
||||
icon: null
|
||||
message: 'Processing payment...'
|
||||
})
|
||||
|
||||
LNbits.api
|
||||
.payInvoice(this.g.wallet, this.send.data.bolt11)
|
||||
.then(function (response) {
|
||||
self.send.paymentChecker = setInterval(function () {
|
||||
.payInvoice(this.g.wallet, this.parse.data.request)
|
||||
.then(response => {
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
setTimeout(() => {
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
}, 40000)
|
||||
this.parse.paymentChecker = setInterval(() => {
|
||||
LNbits.api
|
||||
.getPayment(self.g.wallet, response.data.checking_id)
|
||||
.then(function (res) {
|
||||
.getPayment(this.g.wallet, response.data.payment_hash)
|
||||
.then(res => {
|
||||
if (res.data.paid) {
|
||||
self.send.show = false
|
||||
clearInterval(self.send.paymentChecker)
|
||||
this.parse.show = false
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
dismissPaymentMsg()
|
||||
self.fetchPayments()
|
||||
this.fetchPayments()
|
||||
this.fetchBalance()
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
.catch(err => {
|
||||
dismissPaymentMsg()
|
||||
LNbits.utils.notifyApiError(error)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
payLnurl: function () {
|
||||
let dismissPaymentMsg = this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Processing payment...'
|
||||
})
|
||||
|
||||
LNbits.api
|
||||
.payLnurl(
|
||||
this.g.wallet,
|
||||
this.parse.lnurlpay.callback,
|
||||
this.parse.lnurlpay.description_hash,
|
||||
this.parse.data.amount * 1000,
|
||||
this.parse.lnurlpay.description.slice(0, 120),
|
||||
this.parse.data.comment
|
||||
)
|
||||
.then(response => {
|
||||
this.parse.show = false
|
||||
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
setTimeout(() => {
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
}, 40000)
|
||||
this.parse.paymentChecker = setInterval(() => {
|
||||
LNbits.api
|
||||
.getPayment(this.g.wallet, response.data.payment_hash)
|
||||
.then(res => {
|
||||
if (res.data.paid) {
|
||||
dismissPaymentMsg()
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
this.fetchPayments()
|
||||
this.fetchBalance()
|
||||
|
||||
// show lnurlpay success action
|
||||
if (response.data.success_action) {
|
||||
switch (response.data.success_action.tag) {
|
||||
case 'url':
|
||||
this.$q.notify({
|
||||
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`,
|
||||
caption: response.data.success_action.description,
|
||||
html: true,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
break
|
||||
case 'message':
|
||||
this.$q.notify({
|
||||
message: response.data.success_action.message,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
break
|
||||
case 'aes':
|
||||
LNbits.api
|
||||
.getPayment(this.g.wallet, response.data.payment_hash)
|
||||
.then(({data: payment}) =>
|
||||
decryptLnurlPayAES(
|
||||
response.data.success_action,
|
||||
payment.preimage
|
||||
)
|
||||
)
|
||||
.then(value => {
|
||||
this.$q.notify({
|
||||
message: value,
|
||||
caption: response.data.success_action.description,
|
||||
html: true,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
.catch(err => {
|
||||
dismissPaymentMsg()
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
authLnurl: function () {
|
||||
let dismissAuthMsg = this.$q.notify({
|
||||
timeout: 10,
|
||||
message: 'Performing authentication...'
|
||||
})
|
||||
|
||||
LNbits.api
|
||||
.authLnurl(this.g.wallet, this.parse.lnurlauth.callback)
|
||||
.then(response => {
|
||||
dismissAuthMsg()
|
||||
this.$q.notify({
|
||||
message: `Authentication successful.`,
|
||||
type: 'positive',
|
||||
timeout: 3500
|
||||
})
|
||||
this.parse.show = false
|
||||
})
|
||||
.catch(err => {
|
||||
dismissAuthMsg()
|
||||
if (err.response.data.reason) {
|
||||
this.$q.notify({
|
||||
message: `Authentication failed. ${this.parse.lnurlauth.domain} says:`,
|
||||
caption: err.response.data.reason,
|
||||
type: 'warning',
|
||||
timeout: 5000
|
||||
})
|
||||
} else {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
})
|
||||
},
|
||||
deleteWallet: function (walletId, user) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this wallet?')
|
||||
.onOk(function () {
|
||||
.onOk(() => {
|
||||
LNbits.href.deleteWallet(walletId, user)
|
||||
})
|
||||
},
|
||||
fetchPayments: function (checkPending) {
|
||||
var self = this
|
||||
|
||||
return LNbits.api
|
||||
.getPayments(this.g.wallet, checkPending)
|
||||
.then(function (response) {
|
||||
self.payments = response.data
|
||||
.map(function (obj) {
|
||||
.then(response => {
|
||||
this.payments = response.data
|
||||
.map(obj => {
|
||||
return LNbits.map.payment(obj)
|
||||
})
|
||||
.sort(function (a, b) {
|
||||
.sort((a, b) => {
|
||||
return b.time - a.time
|
||||
})
|
||||
})
|
||||
},
|
||||
fetchBalance: function () {
|
||||
LNbits.api.getWallet(this.g.wallet).then(response => {
|
||||
this.balance = Math.round(response.data.balance / 1000)
|
||||
EventHub.$emit('update-wallet-balance', [
|
||||
this.g.wallet.id,
|
||||
this.balance
|
||||
])
|
||||
})
|
||||
},
|
||||
checkPendingPayments: function () {
|
||||
var dismissMsg = this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Checking pending transactions...',
|
||||
icon: null
|
||||
message: 'Checking pending transactions...'
|
||||
})
|
||||
|
||||
this.fetchPayments(true).then(function () {
|
||||
this.fetchPayments(true).then(() => {
|
||||
dismissMsg()
|
||||
})
|
||||
},
|
||||
@@ -396,14 +622,16 @@ new Vue({
|
||||
},
|
||||
watch: {
|
||||
payments: function () {
|
||||
EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance])
|
||||
this.fetchBalance()
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.fetchBalance()
|
||||
this.fetchPayments()
|
||||
setTimeout(this.checkPendingPayments(), 1200)
|
||||
},
|
||||
mounted: function () {
|
||||
// show disclaimer
|
||||
if (
|
||||
this.$refs.disclaimer &&
|
||||
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
|
||||
@@ -411,5 +639,10 @@ new Vue({
|
||||
this.disclaimerDialog.show = true
|
||||
this.$q.localStorage.set('lnbits.disclaimerShown', true)
|
||||
}
|
||||
|
||||
// listen to incoming payments
|
||||
LNbits.events.onInvoicePaid(this.g.wallet, payment =>
|
||||
this.onPaymentReceived(payment.payment_hash)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import trio # type: ignore
|
||||
from typing import List
|
||||
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
sse_listeners: List[trio.MemorySendChannel] = []
|
||||
|
||||
|
||||
async def register_listeners():
|
||||
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(5)
|
||||
register_invoice_listener(invoice_paid_chan_send)
|
||||
await wait_for_paid_invoices(invoice_paid_chan_recv)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
||||
async for payment in invoice_paid_chan:
|
||||
for send_channel in sse_listeners:
|
||||
try:
|
||||
send_channel.send_nowait(payment)
|
||||
except trio.WouldBlock:
|
||||
print("removing sse listener", send_channel)
|
||||
sse_listeners.remove(send_channel)
|
||||
@@ -4,22 +4,69 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create an invoice (incoming)">
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get wallet details"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">GET</span> /api/v1/wallet</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": "<i>{{ wallet.adminkey }}</i>"}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"id": <string>, "name": <string>, "balance":
|
||||
<int>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl {{ request.url_root }}api/v1/wallet -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create an invoice (incoming)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"out": false, "amount": <int>, "memo": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5>
|
||||
<code>{"checking_id": <string>, "payment_request": <string>}</code>
|
||||
<code
|
||||
>{"out": false, "amount": <int>, "memo": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"payment_hash": <string>, "payment_request":
|
||||
<string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": false, "amount": <int>, "memo": <string>}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": false,
|
||||
"amount": <int>, "memo": <string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Pay an invoice (outgoing)">
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Pay an invoice (outgoing)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
|
||||
@@ -27,24 +74,45 @@
|
||||
<code>{"X-Api-Key": "{{ wallet.adminkey }}"}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"out": true, "bolt11": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5>
|
||||
<code>{"checking_id": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>{"payment_hash": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": true, "bolt11": <string>}' -H "X-Api-Key: {{ wallet.adminkey }}"</i> -H "Content-type: application/json"</code>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": true,
|
||||
"bolt11": <string>}' -H "X-Api-Key:
|
||||
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
|
||||
application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Check an invoice (incoming or outgoing)"
|
||||
class="q-pb-md">
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check an invoice (incoming or outgoing)"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-blue">GET</span> /api/v1/payments/<checking_id></code>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/api/v1/payments/<payment_hash></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"paid": <bool>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code>curl -X GET {{ request.url_root }}api/v1/payments/<checking_id> -H "X-Api-Key: {{ wallet.inkey }}"</i> -H "Content-type: application/json"</code>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}api/v1/payments/<payment_hash> -H "X-Api-Key:
|
||||
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
{{ window_vars(user) }}
|
||||
{% assets filters='rjsmin', output='__bundle__/core/extensions.js',
|
||||
'core/js/extensions.js' %}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{% endassets %}
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-6 col-md-4 col-lg-3" v-for="extension in g.extensions" :key="extension.code">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-icon :name="extension.icon" color="grey-5" style="font-size: 4rem;"></q-icon>
|
||||
{% raw %}
|
||||
<h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5>
|
||||
{{ extension.shortDescription }}
|
||||
{% endraw %}
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-actions>
|
||||
<div v-if="extension.isEnabled">
|
||||
<q-btn flat color="deep-purple"
|
||||
type="a" :href="[extension.url, '?usr=', g.user.id].join('')">Open</q-btn>
|
||||
<q-btn flat color="grey-5"
|
||||
type="a"
|
||||
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&disable=', extension.code].join('')"> Disable</q-btn>
|
||||
</div>
|
||||
<q-btn v-else flat color="deep-purple"
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script src="/core/static/js/extensions.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-6 col-md-4 col-lg-3"
|
||||
v-for="extension in g.extensions"
|
||||
:key="extension.code"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-icon
|
||||
:name="extension.icon"
|
||||
color="grey-5"
|
||||
style="font-size: 4rem"
|
||||
></q-icon>
|
||||
{% raw %}
|
||||
<h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5>
|
||||
{{ extension.shortDescription }} {% endraw %}
|
||||
</q-card-section>
|
||||
<q-separator></q-separator>
|
||||
<q-card-actions>
|
||||
<div v-if="extension.isEnabled">
|
||||
<q-btn
|
||||
flat
|
||||
color="deep-purple"
|
||||
type="a"
|
||||
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')">
|
||||
Enable</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
:href="[extension.url, '?usr=', g.user.id].join('')"
|
||||
>Open</q-btn
|
||||
>
|
||||
<q-btn
|
||||
flat
|
||||
color="grey-5"
|
||||
type="a"
|
||||
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&disable=', extension.code].join('')"
|
||||
>
|
||||
Disable</q-btn
|
||||
>
|
||||
</div>
|
||||
<q-btn
|
||||
v-else
|
||||
flat
|
||||
color="deep-purple"
|
||||
type="a"
|
||||
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')"
|
||||
>
|
||||
Enable</q-btn
|
||||
>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,72 +1,110 @@
|
||||
{% extends "public.html" %}
|
||||
{% extends "public.html" %} {% block scripts %}
|
||||
<script src="/core/static/js/index.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% if lnurl %}
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
@click="processing"
|
||||
type="a"
|
||||
href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}"
|
||||
>
|
||||
Press to claim bitcoin
|
||||
</q-btn>
|
||||
{% else %}
|
||||
<q-form @submit="createWallet" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model="walletName"
|
||||
label="Name your LNbits wallet *"
|
||||
></q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="walletName == ''"
|
||||
type="submit"
|
||||
>Add a new wallet</q-btn
|
||||
>
|
||||
</q-form>
|
||||
{% endif %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
{% assets filters='rjsmin', output='__bundle__/core/index.js',
|
||||
'core/js/index.js' %}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{% endassets %}
|
||||
{% endblock %}
|
||||
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
{% if lnurl %}
|
||||
<q-btn unelevated
|
||||
color="deep-purple"
|
||||
@click="processing"
|
||||
type="a" href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}">
|
||||
Press to claim bitcoin
|
||||
</q-btn>
|
||||
{% else %}
|
||||
<q-form @submit="createWallet" class="q-gutter-md">
|
||||
<q-input filled dense
|
||||
v-model="walletName"
|
||||
label="Name your LNbits wallet *"
|
||||
></q-input>
|
||||
<q-btn unelevated
|
||||
color="deep-purple"
|
||||
:disable="walletName == ''"
|
||||
type="submit">Add a new wallet</q-btn>
|
||||
</q-form>
|
||||
{% endif %}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none"><strong>LN</strong>bits</h3>
|
||||
<h5 class="q-my-md">Free and open-source lightning wallet</h5>
|
||||
<p>Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself!</p>
|
||||
<p>You can run LNbits for yourself, or easily offer a custodian solution for others.</p>
|
||||
<p>Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool.</p>
|
||||
<p>Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.</p>
|
||||
<div class="row q-mt-md q-gutter-sm">
|
||||
<q-btn outline
|
||||
color="grey"
|
||||
type="a" href="https://github.com/lnbits/lnbits" target="_blank" rel="noopener">View project in GitHub</q-btn>
|
||||
<q-btn outline
|
||||
color="grey"
|
||||
type="a" href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK" target="_blank" rel="noopener">Donate</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Ads -->
|
||||
<div class="col-12 col-md-3 col-lg-3">
|
||||
<div class="row q-col-gutter-lg justify-center">
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn flat color="purple" label="Supporters" type="a" href="mailto:lnbits@pm.me" class="full-width"></q-btn>
|
||||
<a href="https://github.com/rootzoll/raspiblitz">
|
||||
<q-img contain :src="($q.dark.isActive) ? '{{ url_for('static', filename='images/raspiblitz-negative.svg') }}' : '{{ url_for('static', filename='images/raspiblitz.svg') }}'" style="height: 90px;"></q-img>
|
||||
</a>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none"><strong>LN</strong>bits</h3>
|
||||
<h5 class="q-my-md">Free and open-source lightning wallet</h5>
|
||||
<p>
|
||||
Easy to set up and lightweight, LNbits can run on any
|
||||
lightning-network funding source, currently supporting LND,
|
||||
c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself!
|
||||
</p>
|
||||
<p>
|
||||
You can run LNbits for yourself, or easily offer a custodian solution
|
||||
for others.
|
||||
</p>
|
||||
<p>
|
||||
Each wallet has its own API keys and there is no limit to the number
|
||||
of wallets you can make. Being able to partition funds makes LNbits a
|
||||
useful tool for money management and as a development tool.
|
||||
</p>
|
||||
<p>
|
||||
Extensions add extra functionality to LNbits so you can experiment
|
||||
with a range of cutting-edge technologies on the lightning network. We
|
||||
have made developing extensions as easy as possible, and as a free and
|
||||
open-source project, we encourage people to develop and submit their
|
||||
own.
|
||||
</p>
|
||||
<div class="row q-mt-md q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://github.com/lnbits/lnbits"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View project in GitHub</q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
type="a"
|
||||
href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Donate</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Ads -->
|
||||
<div class="col-12 col-md-3 col-lg-3">
|
||||
<div class="row q-col-gutter-lg justify-center">
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn
|
||||
flat
|
||||
color="purple"
|
||||
label="Supporters"
|
||||
type="a"
|
||||
href="mailto:lnbits@pm.me"
|
||||
class="full-width"
|
||||
></q-btn>
|
||||
<a href="https://github.com/rootzoll/raspiblitz">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/raspiblitz-negative.svg' : '/static/images/raspiblitz.svg'"
|
||||
style="height: 90px"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,259 +1,543 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
<!---->
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='vendor/vue-qrcode-reader@2.2.0/vue-qrcode-reader.min.css') }}">
|
||||
<!---->
|
||||
{% block scripts %} {{ window_vars(user, wallet) }}
|
||||
<script src="/core/static/js/wallet.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ window_vars(user, wallet) }}
|
||||
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
{% assets filters='rjsmin', output='__bundle__/core/chart.js',
|
||||
'vendor/moment@2.25.1/moment.min.js',
|
||||
'vendor/chart.js@2.9.3/chart.min.js' %}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{% endassets %}
|
||||
{% assets filters='rjsmin', output='__bundle__/core/wallet.js',
|
||||
'vendor/bolt11/utils.js',
|
||||
'vendor/bolt11/decoder.js',
|
||||
'vendor/vue-qrcode-reader@2.2.0/vue-qrcode-reader.min.js',
|
||||
'core/js/wallet.js' %}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{% endassets %}
|
||||
{% endblock %}
|
||||
|
||||
<!---->
|
||||
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
|
||||
<!---->
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none"><strong>{% raw %}{{ fbalance }}{% endraw %}</strong> sat</h3>
|
||||
</q-card-section>
|
||||
<div class="row q-pb-md q-px-md q-col-gutter-md">
|
||||
<div class="col">
|
||||
<q-btn unelevated
|
||||
color="purple"
|
||||
class="full-width"
|
||||
@click="showSendDialog">Send</q-btn>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn unelevated
|
||||
color="deep-purple"
|
||||
class="full-width"
|
||||
@click="showReceiveDialog">Receive</q-btn>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h3 class="q-my-none">
|
||||
<strong>{% raw %}{{ formattedBalance }}{% endraw %}</strong> sat
|
||||
</h3>
|
||||
</q-card-section>
|
||||
<div class="row q-pb-md q-px-md q-col-gutter-md">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
class="full-width"
|
||||
@click="showParseDialog"
|
||||
>Paste Request</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
class="full-width"
|
||||
@click="showReceiveDialog"
|
||||
>Create Invoice</q-btn
|
||||
>
|
||||
</div>
|
||||
<div class="col">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="purple"
|
||||
icon="photo_camera"
|
||||
@click="showCamera"
|
||||
>scan
|
||||
<q-tooltip>Use camera to scan an invoice/QR</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-sm">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
|
||||
<q-tooltip>Check pending</q-tooltip>
|
||||
</q-btn>-->
|
||||
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart">
|
||||
<q-tooltip>Show chart</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="show_chart"
|
||||
color="grey"
|
||||
@click="showChart"
|
||||
>
|
||||
<q-tooltip>Show chart</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<q-input v-if="payments.length > 10" filled dense clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300" placeholder="Search by memo, amount" class="q-mb-md">
|
||||
</q-input>
|
||||
<q-table dense flat
|
||||
:data="filteredPayments"
|
||||
row-key="payhash"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination">
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props">{{ col.label }}</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width class="lnbits__q-table__icon-td">
|
||||
<q-icon v-if="props.row.isPaid" size="14px"
|
||||
:name="(props.row.sat < 0) ? 'call_made' : 'call_received'"
|
||||
:color="(props.row.sat < 0) ? 'pink' : 'green'"></q-icon>
|
||||
<q-icon v-else name="settings_ethernet" color="grey">
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td key="memo" :props="props">
|
||||
{{ props.row.memo }}
|
||||
</q-td>
|
||||
<q-td auto-width key="date" :props="props">
|
||||
{{ props.row.date }}
|
||||
</q-td>
|
||||
<q-td auto-width key="sat" :props="props">
|
||||
{{ props.row.fsat }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
v-if="payments.length > 10"
|
||||
filled
|
||||
dense
|
||||
clearable
|
||||
v-model="paymentsTable.filter"
|
||||
debounce="300"
|
||||
placeholder="Search by tag, memo, amount"
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="filteredPayments"
|
||||
row-key="payment_hash"
|
||||
:columns="paymentsTable.columns"
|
||||
:pagination.sync="paymentsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props"
|
||||
>{{ col.label }}</q-th
|
||||
>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width class="text-center">
|
||||
<q-icon
|
||||
v-if="props.row.isPaid"
|
||||
size="14px"
|
||||
:name="props.row.isOut ? 'call_made' : 'call_received'"
|
||||
:color="props.row.isOut ? 'pink' : 'green'"
|
||||
@click="props.expand = !props.expand"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
v-else
|
||||
name="settings_ethernet"
|
||||
color="grey"
|
||||
@click="props.expand = !props.expand"
|
||||
>
|
||||
<q-tooltip>Pending</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td
|
||||
key="memo"
|
||||
:props="props"
|
||||
style="white-space: normal; word-break: break-all"
|
||||
>
|
||||
<q-badge v-if="props.row.tag" color="yellow" text-color="black">
|
||||
<a
|
||||
class="inherit"
|
||||
:href="['/', props.row.tag, '?usr=', user.id].join('')"
|
||||
>
|
||||
#{{ props.row.tag }}
|
||||
</a>
|
||||
</q-badge>
|
||||
{{ props.row.memo }}
|
||||
</q-td>
|
||||
<q-td auto-width key="date" :props="props">
|
||||
<q-tooltip>{{ props.row.date }}</q-tooltip>
|
||||
{{ props.row.dateFrom }}
|
||||
</q-td>
|
||||
<q-td auto-width key="sat" :props="props">
|
||||
{{ props.row.fsat }}
|
||||
</q-td>
|
||||
<q-td auto-width key="fee" :props="props">
|
||||
{{ props.row.fee }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn flat color="grey" @click="exportCSV" class="float-right">Renew keys</q-btn>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">LNbits wallet</h6>
|
||||
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br>
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br>
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br>
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "core/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="remove_circle"
|
||||
label="Delete wallet">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>This whole wallet will be deleted, the funds will be <strong>UNRECOVERABLE</strong>.</p>
|
||||
<q-btn unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')">Delete wallet</q-btn>
|
||||
</q-card-section>
|
||||
<q-dialog v-model="props.expand" :props="props">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isIn && props.row.pending">
|
||||
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||
Invoice waiting to be paid
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + props.row.bolt11">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="props.row.bolt11"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(props.row.bolt11)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Close</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isIn">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
Payment Received
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isOut">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_made'"
|
||||
:color="'pink'"
|
||||
></q-icon>
|
||||
Payment Sent
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
<div v-else-if="props.row.isOut && props.row.pending">
|
||||
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||
Outgoing payment pending
|
||||
<lnbits-payment-details
|
||||
:payment="props.row"
|
||||
></lnbits-payment-details>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
|
||||
<q-card v-if="!receive.paymentReq" class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="createInvoice" class="q-gutter-md">
|
||||
<q-input filled dense
|
||||
v-model.number="receive.data.amount"
|
||||
type="number"
|
||||
label="Amount (sat) *"></q-input>
|
||||
<q-input filled dense
|
||||
v-model.trim="receive.data.memo"
|
||||
label="Memo"
|
||||
placeholder="LNbits invoice"></q-input>
|
||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||
<q-btn unelevated
|
||||
color="deep-purple"
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit">Create invoice</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
<q-spinner v-if="receive.status == 'loading'" color="deep-purple" size="2.55em"></q-spinner>
|
||||
</q-form>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode :value="receive.paymentReq" :options="{width: 340}" class="rounded-borders"></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText(receive.paymentReq)">Copy invoice</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="send.show" position="top" @hide="closeSendDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div v-if="!send.invoice">
|
||||
<q-form v-if="!sendCamera.show" @submit="decodeInvoice" class="q-gutter-md">
|
||||
<q-input filled dense
|
||||
v-model.trim="send.data.bolt11"
|
||||
type="textarea"
|
||||
label="Paste an invoice *">
|
||||
<template v-slot:after>
|
||||
<q-btn round dense flat icon="photo_camera" @click="showCamera">
|
||||
<q-tooltip>Use camera to scan an invoice</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated
|
||||
color="deep-purple"
|
||||
:disable="send.data.bolt11 == ''"
|
||||
type="submit">Read invoice</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
<div v-else>
|
||||
<q-responsive :ratio="1">
|
||||
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
|
||||
</q-responsive>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{% raw %}
|
||||
<h6 class="q-my-none">{{ send.invoice.fsat }} sat</h6>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p style="word-break: break-all">
|
||||
<strong>Memo:</strong> {{ send.invoice.description }}<br>
|
||||
<strong>Expire date:</strong> {{ send.invoice.expireDate }}<br>
|
||||
<strong>Hash:</strong> {{ send.invoice.hash }}
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div v-if="canPay" class="row q-mt-lg">
|
||||
<q-btn unelevated
|
||||
color="deep-purple"
|
||||
@click="payInvoice">Send satoshis</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
<div v-else class="row q-mt-lg">
|
||||
<q-btn unelevated disabled color="yellow" text-color="black">Not enough funds!</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="paymentsChart.show" position="top">
|
||||
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
||||
<q-card-section>
|
||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||
</q-dialog>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn flat color="grey" @click="exportCSV" class="float-right"
|
||||
>Renew keys</q-btn
|
||||
>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">LNbits wallet</h6>
|
||||
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br />
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "core/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="remove_circle"
|
||||
label="Delete wallet"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This whole wallet will be deleted, the funds will be
|
||||
<strong>UNRECOVERABLE</strong>.
|
||||
</p>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
||||
>Delete wallet</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if service_fee > 0 %}
|
||||
<div ref="disclaimer"></div>
|
||||
<q-dialog v-model="disclaimerDialog.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-deep-purple">Warning</h6>
|
||||
<p>Login functionality to be released in v0.2, for now, <strong>make sure you bookmark this page for future access to your wallet</strong>!</p>
|
||||
<p>This service is in BETA, and we hold no responsibility for people losing access to funds. To encourage you to run your own LNbits installation, any balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will incur a charge of <strong>{{ service_fee }}% service fee</strong> per week.</p>
|
||||
<q-dialog v-model="receive.show" @hide="closeReceiveDialog">
|
||||
{% raw %}
|
||||
<q-card
|
||||
v-if="!receive.paymentReq"
|
||||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<q-form @submit="createInvoice" class="q-gutter-md">
|
||||
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
|
||||
<b>{{receive.lnurl.domain}}</b> is requesting an invoice:
|
||||
</p>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="receive.data.amount"
|
||||
type="number"
|
||||
label="Amount (sat) *"
|
||||
:min="receive.minMax[0]"
|
||||
:max="receive.minMax[1]"
|
||||
:readonly="receive.lnurl && receive.lnurl.fixed"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="receive.data.memo"
|
||||
label="Memo"
|
||||
placeholder="LNbits invoice"
|
||||
></q-input>
|
||||
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="receive.data.amount == null || receive.data.amount <= 0"
|
||||
type="submit"
|
||||
>
|
||||
<span v-if="receive.lnurl">
|
||||
Withdraw from {{receive.lnurl.domain}}
|
||||
</span>
|
||||
<span v-else> Create invoice </span>
|
||||
</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
<q-spinner
|
||||
v-if="receive.status == 'loading'"
|
||||
color="deep-purple"
|
||||
size="2.55em"
|
||||
></q-spinner>
|
||||
</q-form>
|
||||
</q-card>
|
||||
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="'lightning:' + receive.paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl">
|
||||
<qrcode
|
||||
:value="receive.paymentReq"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
{% endraw %}
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="parse.show" @hide="closeParseDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div v-if="parse.invoice">
|
||||
{% raw %}
|
||||
<h6 class="q-my-none">{{ parse.invoice.fsat }} sat</h6>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p class="text-wrap">
|
||||
<strong>Description:</strong> {{ parse.invoice.description }}<br />
|
||||
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br />
|
||||
<strong>Hash:</strong> {{ parse.invoice.hash }}
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div v-if="canPay" class="row q-mt-lg">
|
||||
<q-btn unelevated color="deep-purple" @click="payInvoice">Pay</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
<div v-else class="row q-mt-lg">
|
||||
<q-btn unelevated disabled color="yellow" text-color="black"
|
||||
>Not enough funds!</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlauth">
|
||||
{% raw %}
|
||||
<q-form @submit="authLnurl" class="q-gutter-md">
|
||||
<p class="q-my-none text-h6">
|
||||
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>?
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<p>
|
||||
For every website and for every LNbits wallet, a new keypair will be
|
||||
deterministically generated so your identity can't be tied to your
|
||||
LNbits wallet or linked across websites. No other data will be shared
|
||||
with {{ parse.lnurlauth.domain }}.
|
||||
</p>
|
||||
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
|
||||
<p class="q-mx-xl">
|
||||
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code>
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText(disclaimerDialog.location.href)">Copy wallet URL</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">I understand</q-btn>
|
||||
<q-btn unelevated color="deep-purple" type="submit">Login</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else-if="parse.lnurlpay">
|
||||
{% raw %}
|
||||
<q-form @submit="payLnurl" class="q-gutter-md">
|
||||
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
|
||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{
|
||||
parse.lnurlpay.maxSendable | msatoshiFormat }} sat
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
</span>
|
||||
</p>
|
||||
<p v-else class="q-my-none text-h6 text-center">
|
||||
<b>{{ parse.lnurlpay.domain }}</b> is requesting <br />
|
||||
between <b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and
|
||||
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b> sat
|
||||
<span v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<br />
|
||||
and a {{parse.lnurlpay.commentAllowed}}-char comment
|
||||
</span>
|
||||
</p>
|
||||
<q-separator class="q-my-sm"></q-separator>
|
||||
<div class="row">
|
||||
<p class="col text-justify text-italic">
|
||||
{{ parse.lnurlpay.description }}
|
||||
</p>
|
||||
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
|
||||
<q-img :src="parse.lnurlpay.image" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="parse.data.amount"
|
||||
type="number"
|
||||
label="Amount (sat) *"
|
||||
:min="parse.lnurlpay.minSendable / 1000"
|
||||
:max="parse.lnurlpay.maxSendable / 1000"
|
||||
:readonly="parse.lnurlpay.fixed"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-8 q-pl-md" v-if="parse.lnurlpay.commentAllowed > 0">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="parse.data.comment"
|
||||
:type="parse.lnurlpay.commentAllowed > 64 ? 'textarea' : 'text'"
|
||||
label="Comment (optional)"
|
||||
:maxlength="parse.lnurlpay.commentAllowed"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn unelevated color="deep-purple" type="submit"
|
||||
>Send satoshis</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
{% endraw %}
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-form
|
||||
v-if="!parse.camera.show"
|
||||
@submit="decodeRequest"
|
||||
class="q-gutter-md"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="parse.data.request"
|
||||
type="textarea"
|
||||
label="Paste an invoice, payment request or lnurl code *"
|
||||
>
|
||||
</q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="parse.data.request == ''"
|
||||
type="submit"
|
||||
>Read</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
<div v-else>
|
||||
<q-responsive :ratio="1">
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</q-responsive>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">
|
||||
Cancel
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="parse.camera.show">
|
||||
<q-card class="q-pa-lg q-pt-xl">
|
||||
<div class="text-center q-mb-lg">
|
||||
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="paymentsChart.show">
|
||||
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
|
||||
<q-card-section>
|
||||
<canvas ref="canvas" width="600" height="400"></canvas>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
{% if service_fee > 0 %}
|
||||
<div ref="disclaimer"></div>
|
||||
<q-dialog v-model="disclaimerDialog.show">
|
||||
<q-card class="q-pa-lg">
|
||||
<h6 class="q-my-md text-deep-purple">Warning</h6>
|
||||
<p>
|
||||
Login functionality to be released in v0.2, for now,
|
||||
<strong
|
||||
>make sure you bookmark this page for future access to your
|
||||
wallet</strong
|
||||
>!
|
||||
</p>
|
||||
<p>
|
||||
This service is in BETA, and we hold no responsibility for people losing
|
||||
access to funds. To encourage you to run your own LNbits installation, any
|
||||
balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will
|
||||
incur a charge of <strong>{{ service_fee }}% service fee</strong> per
|
||||
week.
|
||||
</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(disclaimerDialog.location.href)"
|
||||
>Copy wallet URL</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>I understand</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
{% endif %} {% endblock %}
|
||||
|
||||
+323
-37
@@ -1,89 +1,375 @@
|
||||
from flask import g, jsonify, request
|
||||
import trio # type: ignore
|
||||
import json
|
||||
import lnurl # type: ignore
|
||||
import httpx
|
||||
import traceback
|
||||
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
|
||||
from quart import g, jsonify, request, make_response
|
||||
from http import HTTPStatus
|
||||
from binascii import unhexlify
|
||||
from typing import Dict, Union
|
||||
|
||||
from lnbits.core import core_app
|
||||
from lnbits import bolt11
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
from ..services import create_invoice, pay_invoice
|
||||
from .. import core_app
|
||||
from ..services import create_invoice, pay_invoice, perform_lnurlauth
|
||||
from ..crud import delete_expired_invoices
|
||||
from ..tasks import sse_listeners
|
||||
|
||||
|
||||
@core_app.route("/api/v1/wallet", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_wallet():
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"id": g.wallet.id,
|
||||
"name": g.wallet.name,
|
||||
"balance": g.wallet.balance_msat,
|
||||
}
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@core_app.route("/api/v1/payments", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_payments():
|
||||
async def api_payments():
|
||||
if "check_pending" in request.args:
|
||||
g.wallet.delete_expired_payments()
|
||||
delete_expired_invoices()
|
||||
|
||||
for payment in g.wallet.get_payments(include_all_pending=True):
|
||||
if payment.is_out:
|
||||
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
|
||||
else:
|
||||
payment.set_pending(WALLET.get_invoice_status(payment.checking_id).pending)
|
||||
for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True):
|
||||
payment.check_pending()
|
||||
|
||||
return jsonify(g.wallet.get_payments()), HTTPStatus.OK
|
||||
return jsonify(g.wallet.get_payments(pending=True)), HTTPStatus.OK
|
||||
|
||||
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"amount": {"type": "integer", "min": 1, "required": True},
|
||||
"memo": {"type": "string", "empty": False, "required": True},
|
||||
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
|
||||
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
|
||||
"lnurl_callback": {"type": "string", "nullable": True, "required": False},
|
||||
}
|
||||
)
|
||||
def api_payments_create_invoice():
|
||||
async def api_payments_create_invoice():
|
||||
if "description_hash" in g.data:
|
||||
description_hash = unhexlify(g.data["description_hash"])
|
||||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
memo = g.data["memo"]
|
||||
|
||||
try:
|
||||
checking_id, payment_request = create_invoice(
|
||||
wallet_id=g.wallet.id, amount=g.data["amount"], memo=g.data["memo"]
|
||||
payment_hash, payment_request = create_invoice(
|
||||
wallet_id=g.wallet.id, amount=g.data["amount"], memo=memo, description_hash=description_hash
|
||||
)
|
||||
except Exception as e:
|
||||
g.db.rollback()
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.CREATED
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
lnurl_response: Union[None, bool, str] = None
|
||||
if g.data.get("lnurl_callback"):
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
g.data["lnurl_callback"],
|
||||
params={"pr": payment_request},
|
||||
timeout=10,
|
||||
)
|
||||
if r.is_error:
|
||||
lnurl_response = r.text
|
||||
else:
|
||||
resp = json.loads(r.text)
|
||||
if resp["status"] != "OK":
|
||||
lnurl_response = resp["reason"]
|
||||
else:
|
||||
lnurl_response = True
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
lnurl_response = False
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"payment_request": payment_request,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": invoice.payment_hash,
|
||||
"lnurl_response": lnurl_response,
|
||||
}
|
||||
),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(schema={"bolt11": {"type": "string", "empty": False, "required": True}})
|
||||
def api_payments_pay_invoice():
|
||||
async def api_payments_pay_invoice():
|
||||
try:
|
||||
checking_id = pay_invoice(wallet_id=g.wallet.id, bolt11=g.data["bolt11"])
|
||||
payment_hash = pay_invoice(wallet_id=g.wallet.id, payment_request=g.data["bolt11"])
|
||||
except ValueError as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
|
||||
except PermissionError as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
except Exception as exc:
|
||||
traceback.print_exc(7)
|
||||
g.db.rollback()
|
||||
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
return jsonify({"checking_id": checking_id}), HTTPStatus.CREATED
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@core_app.route("/api/v1/payments", methods=["POST"])
|
||||
@api_validate_post_request(schema={"out": {"type": "boolean", "required": True}})
|
||||
def api_payments_create():
|
||||
async def api_payments_create():
|
||||
if g.data["out"] is True:
|
||||
return api_payments_pay_invoice()
|
||||
return api_payments_create_invoice()
|
||||
return await api_payments_pay_invoice()
|
||||
return await api_payments_create_invoice()
|
||||
|
||||
|
||||
@core_app.route("/api/v1/payments/<checking_id>", methods=["GET"])
|
||||
@core_app.route("/api/v1/payments/lnurl", methods=["POST"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"description_hash": {"type": "string", "empty": False, "required": True},
|
||||
"callback": {"type": "string", "empty": False, "required": True},
|
||||
"amount": {"type": "number", "empty": False, "required": True},
|
||||
"comment": {"type": "string", "nullable": True, "empty": True, "required": False},
|
||||
"description": {"type": "string", "nullable": True, "empty": True, "required": False},
|
||||
}
|
||||
)
|
||||
async def api_payments_pay_lnurl():
|
||||
domain = urlparse(g.data["callback"]).netloc
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.get(
|
||||
g.data["callback"],
|
||||
params={"amount": g.data["amount"], "comment": g.data["comment"]},
|
||||
timeout=40,
|
||||
)
|
||||
if r.is_error:
|
||||
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
return jsonify({"message": "failed to connect"}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
params = json.loads(r.text)
|
||||
if params.get("status") == "ERROR":
|
||||
return jsonify({"message": f"{domain} said: '{params.get('reason', '')}'"}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
invoice = bolt11.decode(params["pr"])
|
||||
if invoice.amount_msat != g.data["amount"]:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": f"{domain} returned an invalid invoice. Expected {g.data['amount']} msat, got {invoice.amount_msat}."
|
||||
}
|
||||
),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
if invoice.description_hash != g.data["description_hash"]:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": f"{domain} returned an invalid invoice. Expected description_hash == {g.data['description_hash']}, got {invoice.description_hash}."
|
||||
}
|
||||
),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
extra = {}
|
||||
|
||||
if params.get("successAction"):
|
||||
extra["success_action"] = params["successAction"]
|
||||
if g.data["comment"]:
|
||||
extra["comment"] = g.data["comment"]
|
||||
|
||||
payment_hash = pay_invoice(
|
||||
wallet_id=g.wallet.id,
|
||||
payment_request=params["pr"],
|
||||
description=g.data.get("description", ""),
|
||||
extra=extra,
|
||||
)
|
||||
except Exception as exc:
|
||||
traceback.print_exc(7)
|
||||
g.db.rollback()
|
||||
return jsonify({"message": str(exc)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"success_action": params.get("successAction"),
|
||||
"payment_hash": payment_hash,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": payment_hash,
|
||||
}
|
||||
),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_payment(checking_id):
|
||||
payment = g.wallet.get_payment(checking_id)
|
||||
async def api_payment(payment_hash):
|
||||
payment = g.wallet.get_payment(payment_hash)
|
||||
|
||||
if not payment:
|
||||
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
|
||||
elif not payment.pending:
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
return jsonify({"paid": True, "preimage": payment.preimage}), HTTPStatus.OK
|
||||
|
||||
try:
|
||||
if payment.is_out:
|
||||
is_paid = not WALLET.get_payment_status(checking_id).pending
|
||||
elif payment.is_in:
|
||||
is_paid = not WALLET.get_invoice_status(checking_id).pending
|
||||
payment.check_pending()
|
||||
except Exception:
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
if is_paid:
|
||||
payment.set_pending(False)
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
return jsonify({"paid": not payment.pending, "preimage": payment.preimage}), HTTPStatus.OK
|
||||
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
@core_app.route("/api/v1/payments/sse", methods=["GET"])
|
||||
@api_check_wallet_key("invoice", accept_querystring=True)
|
||||
async def api_payments_sse():
|
||||
g.db.close()
|
||||
this_wallet_id = g.wallet.id
|
||||
|
||||
send_payment, receive_payment = trio.open_memory_channel(0)
|
||||
|
||||
print("adding sse listener", send_payment)
|
||||
sse_listeners.append(send_payment)
|
||||
|
||||
send_event, event_to_send = trio.open_memory_channel(0)
|
||||
|
||||
async def payment_received() -> None:
|
||||
async for payment in receive_payment:
|
||||
if payment.wallet_id == this_wallet_id:
|
||||
await send_event.send(("payment-received", payment))
|
||||
|
||||
async def repeat_keepalive():
|
||||
await trio.sleep(1)
|
||||
while True:
|
||||
await send_event.send(("keepalive", ""))
|
||||
await trio.sleep(25)
|
||||
|
||||
g.nursery.start_soon(payment_received)
|
||||
g.nursery.start_soon(repeat_keepalive)
|
||||
|
||||
async def send_events():
|
||||
try:
|
||||
async for typ, data in event_to_send:
|
||||
message = [f"event: {typ}".encode("utf-8")]
|
||||
|
||||
if data:
|
||||
jdata = json.dumps(dict(data._asdict(), pending=False))
|
||||
message.append(f"data: {jdata}".encode("utf-8"))
|
||||
|
||||
yield b"\n".join(message) + b"\r\n\r\n"
|
||||
except trio.Cancelled:
|
||||
return
|
||||
|
||||
response = await make_response(
|
||||
send_events(),
|
||||
{
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
"Transfer-Encoding": "chunked",
|
||||
},
|
||||
)
|
||||
response.timeout = None
|
||||
return response
|
||||
|
||||
|
||||
@core_app.route("/api/v1/lnurlscan/<code>", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_lnurlscan(code: str):
|
||||
try:
|
||||
url = lnurl.Lnurl(code)
|
||||
except ValueError:
|
||||
return jsonify({"message": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
domain = urlparse(url.url).netloc
|
||||
|
||||
# params is what will be returned to the client
|
||||
params: Dict = {"domain": domain}
|
||||
|
||||
if url.is_login:
|
||||
params.update(kind="auth")
|
||||
params.update(callback=url.url) # with k1 already in it
|
||||
|
||||
lnurlauth_key = g.wallet.lnurlauth_key(domain)
|
||||
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
|
||||
else:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url.url, timeout=40)
|
||||
if r.is_error:
|
||||
return (
|
||||
jsonify({"domain": domain, "message": "failed to get parameters"}),
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
try:
|
||||
jdata = json.loads(r.text)
|
||||
data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
|
||||
except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
|
||||
return (
|
||||
jsonify({"domain": domain, "message": f"got invalid response '{r.text[:200]}'"}),
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
if type(data) is lnurl.LnurlChannelResponse:
|
||||
return jsonify({"domain": domain, "kind": "channel", "message": "unsupported"}), HTTPStatus.BAD_REQUEST
|
||||
|
||||
params.update(**data.dict())
|
||||
|
||||
if type(data) is lnurl.LnurlWithdrawResponse:
|
||||
params.update(kind="withdraw")
|
||||
params.update(fixed=data.min_withdrawable == data.max_withdrawable)
|
||||
|
||||
# callback with k1 already in it
|
||||
parsed_callback: ParseResult = urlparse(data.callback)
|
||||
qs: Dict = parse_qs(parsed_callback.query)
|
||||
qs["k1"] = data.k1
|
||||
parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
|
||||
params.update(callback=urlunparse(parsed_callback))
|
||||
|
||||
if type(data) is lnurl.LnurlPayResponse:
|
||||
params.update(kind="pay")
|
||||
params.update(fixed=data.min_sendable == data.max_sendable)
|
||||
params.update(description_hash=data.metadata.h)
|
||||
params.update(description=data.metadata.text)
|
||||
if data.metadata.images:
|
||||
image = min(data.metadata.images, key=lambda image: len(image[1]))
|
||||
data_uri = "data:" + image[0] + "," + image[1]
|
||||
params.update(image=data_uri)
|
||||
params.update(commentAllowed=jdata.get("commentAllowed", 0))
|
||||
|
||||
return jsonify(params)
|
||||
|
||||
|
||||
@core_app.route("/api/v1/lnurlauth", methods=["POST"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"callback": {"type": "string", "required": True},
|
||||
}
|
||||
)
|
||||
async def api_perform_lnurlauth():
|
||||
err = await perform_lnurlauth(g.data["callback"])
|
||||
if err:
|
||||
return jsonify({"reason": err.reason}), HTTPStatus.SERVICE_UNAVAILABLE
|
||||
return "", HTTPStatus.OK
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from flask import g, abort, redirect, request, render_template, send_from_directory, url_for
|
||||
import trio # type: ignore
|
||||
import httpx
|
||||
from os import path
|
||||
from http import HTTPStatus
|
||||
from os import getenv, path
|
||||
from quart import g, abort, redirect, request, render_template, send_from_directory, url_for
|
||||
from lnurl import LnurlResponse, LnurlWithdrawResponse, decode as decode_lnurl # type: ignore
|
||||
|
||||
from lnbits.core import core_app
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.settings import SERVICE_FEE
|
||||
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
|
||||
from lnbits.tasks import run_on_pseudo_request
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
@@ -13,22 +17,23 @@ from ..crud import (
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
)
|
||||
from ..services import redeem_lnurl_withdraw
|
||||
|
||||
|
||||
@core_app.route("/favicon.ico")
|
||||
def favicon():
|
||||
return send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
|
||||
async def favicon():
|
||||
return await send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
|
||||
|
||||
|
||||
@core_app.route("/")
|
||||
def home():
|
||||
return render_template("core/index.html", lnurl=request.args.get("lightning", None))
|
||||
async def home():
|
||||
return await render_template("core/index.html", lnurl=request.args.get("lightning", None))
|
||||
|
||||
|
||||
@core_app.route("/extensions")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
def extensions():
|
||||
async def extensions():
|
||||
extension_to_enable = request.args.get("enable", type=str)
|
||||
extension_to_disable = request.args.get("disable", type=str)
|
||||
|
||||
@@ -40,12 +45,12 @@ def extensions():
|
||||
elif extension_to_disable:
|
||||
update_user_extension(user_id=g.user.id, extension=extension_to_disable, active=0)
|
||||
|
||||
return render_template("core/extensions.html", user=get_user(g.user.id))
|
||||
return await render_template("core/extensions.html", user=get_user(g.user.id))
|
||||
|
||||
|
||||
@core_app.route("/wallet")
|
||||
@validate_uuids(["usr", "wal"])
|
||||
def wallet():
|
||||
async def wallet():
|
||||
user_id = request.args.get("usr", type=str)
|
||||
wallet_id = request.args.get("wal", type=str)
|
||||
wallet_name = request.args.get("nme", type=str)
|
||||
@@ -61,10 +66,9 @@ def wallet():
|
||||
user = get_user(create_account().id)
|
||||
else:
|
||||
user = get_user(user_id) or abort(HTTPStatus.NOT_FOUND, "User does not exist.")
|
||||
allowed_users = getenv("LNBITS_ALLOWED_USERS", "all")
|
||||
|
||||
if allowed_users != "all" and user_id not in allowed_users.split(","):
|
||||
abort(HTTPStatus.UNAUTHORIZED, f"User not authorized.")
|
||||
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
|
||||
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
|
||||
|
||||
if not wallet_id:
|
||||
if user.wallets and not wallet_name:
|
||||
@@ -74,16 +78,17 @@ def wallet():
|
||||
|
||||
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
|
||||
|
||||
if wallet_id not in user.wallet_ids:
|
||||
wallet = user.get_wallet(wallet_id)
|
||||
if not wallet:
|
||||
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
|
||||
return render_template("core/wallet.html", user=user, wallet=user.get_wallet(wallet_id), service_fee=service_fee)
|
||||
return await render_template("core/wallet.html", user=user, wallet=wallet, service_fee=service_fee)
|
||||
|
||||
|
||||
@core_app.route("/deletewallet")
|
||||
@validate_uuids(["usr", "wal"], required=True)
|
||||
@check_user_exists()
|
||||
def deletewallet():
|
||||
async def deletewallet():
|
||||
wallet_id = request.args.get("wal", type=str)
|
||||
user_wallet_ids = g.user.wallet_ids
|
||||
|
||||
@@ -97,3 +102,32 @@ def deletewallet():
|
||||
return redirect(url_for("core.wallet", usr=g.user.id, wal=user_wallet_ids[0]))
|
||||
|
||||
return redirect(url_for("core.home"))
|
||||
|
||||
|
||||
@core_app.route("/lnurlwallet")
|
||||
async def lnurlwallet():
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
lnurl = decode_lnurl(request.args.get("lightning"))
|
||||
r = await client.get(str(lnurl))
|
||||
withdraw_res = LnurlResponse.from_dict(r.json())
|
||||
|
||||
if not withdraw_res.ok:
|
||||
return f"Could not process lnurl-withdraw: {withdraw_res.error_msg}", HTTPStatus.BAD_REQUEST
|
||||
|
||||
if not isinstance(withdraw_res, LnurlWithdrawResponse):
|
||||
return f"Expected an lnurl-withdraw code, got {withdraw_res.tag}", HTTPStatus.BAD_REQUEST
|
||||
except Exception as exc:
|
||||
return f"Could not process lnurl-withdraw: {exc}", HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
account = create_account()
|
||||
user = get_user(account.id)
|
||||
wallet = create_wallet(user_id=user.id)
|
||||
g.db.commit()
|
||||
|
||||
await run_on_pseudo_request(
|
||||
redeem_lnurl_withdraw, wallet.id, withdraw_res, "LNbits initial funding: voucher redeem."
|
||||
)
|
||||
await trio.sleep(3)
|
||||
|
||||
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import requests
|
||||
|
||||
from flask import abort, redirect, request, url_for
|
||||
from http import HTTPStatus
|
||||
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore
|
||||
from lnurl.exceptions import LnurlException # type: ignore
|
||||
from time import sleep
|
||||
|
||||
from lnbits.core import core_app
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
from ..crud import create_account, get_user, create_wallet, create_payment
|
||||
|
||||
|
||||
@core_app.route("/lnurlwallet")
|
||||
def lnurlwallet():
|
||||
memo = "LNbits LNURL funding"
|
||||
|
||||
try:
|
||||
withdraw_res = handle_lnurl(request.args.get("lightning"))
|
||||
if not withdraw_res.ok:
|
||||
abort(HTTPStatus.BAD_REQUEST, f"Could not process LNURL-withdraw: {withdraw_res.error_msg}")
|
||||
if not isinstance(withdraw_res, LnurlWithdrawResponse):
|
||||
abort(HTTPStatus.BAD_REQUEST, "Not a valid LNURL-withdraw.")
|
||||
except LnurlException:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process LNURL-withdraw.")
|
||||
|
||||
try:
|
||||
ok, checking_id, payment_request, error_message = WALLET.create_invoice(withdraw_res.max_sats, memo)
|
||||
except Exception as e:
|
||||
ok, error_message = False, str(e)
|
||||
|
||||
if not ok:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, error_message)
|
||||
|
||||
r = requests.get(
|
||||
withdraw_res.callback.base,
|
||||
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
|
||||
)
|
||||
|
||||
if not r.ok:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process LNURL-withdraw.")
|
||||
|
||||
for i in range(10):
|
||||
invoice_status = WALLET.get_invoice_status(checking_id)
|
||||
sleep(i)
|
||||
if not invoice_status.paid:
|
||||
continue
|
||||
break
|
||||
|
||||
user = get_user(create_account().id)
|
||||
wallet = create_wallet(user_id=user.id)
|
||||
create_payment(
|
||||
wallet_id=wallet.id,
|
||||
checking_id=checking_id,
|
||||
amount=withdraw_res.max_sats * 1000,
|
||||
memo=memo,
|
||||
pending=invoice_status.pending,
|
||||
)
|
||||
|
||||
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
|
||||
+30
-6
@@ -10,27 +10,51 @@ class Database:
|
||||
self.connection = sqlite3.connect(db_path)
|
||||
self.connection.row_factory = sqlite3.Row
|
||||
self.cursor = self.connection.cursor()
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.__exit__(None, None, None)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.cursor.close()
|
||||
self.connection.close()
|
||||
if self.closed:
|
||||
return
|
||||
|
||||
if exc_val:
|
||||
self.connection.rollback()
|
||||
self.cursor.close()
|
||||
self.connection.close()
|
||||
else:
|
||||
self.connection.commit()
|
||||
self.cursor.close()
|
||||
self.connection.close()
|
||||
|
||||
self.closed = True
|
||||
|
||||
def commit(self):
|
||||
self.connection.commit()
|
||||
|
||||
def rollback(self):
|
||||
self.connection.rollback()
|
||||
|
||||
def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||
"""Given a query, return cursor.fetchall() rows."""
|
||||
self.cursor.execute(query, values)
|
||||
self.execute(query, values)
|
||||
return self.cursor.fetchall()
|
||||
|
||||
def fetchone(self, query: str, values: tuple = ()):
|
||||
self.cursor.execute(query, values)
|
||||
self.execute(query, values)
|
||||
return self.cursor.fetchone()
|
||||
|
||||
def execute(self, query: str, values: tuple = ()) -> None:
|
||||
"""Given a query, cursor.execute() it."""
|
||||
self.cursor.execute(query, values)
|
||||
self.connection.commit()
|
||||
try:
|
||||
self.cursor.execute(query, values)
|
||||
except sqlite3.Error as exc:
|
||||
self.connection.rollback()
|
||||
raise exc
|
||||
|
||||
|
||||
def open_db(db_name: str = "database") -> Database:
|
||||
|
||||
+29
-19
@@ -1,27 +1,31 @@
|
||||
from cerberus import Validator # type: ignore
|
||||
from flask import g, abort, jsonify, request
|
||||
from quart import g, abort, jsonify, request
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
from os import getenv
|
||||
from typing import List, Union
|
||||
from uuid import UUID
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from lnbits.settings import LNBITS_ALLOWED_USERS
|
||||
|
||||
|
||||
def api_check_wallet_key(key_type: str = "invoice"):
|
||||
def api_check_wallet_key(key_type: str = "invoice", accept_querystring=False):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
async def wrapped_view(**kwargs):
|
||||
try:
|
||||
g.wallet = get_wallet_for_key(request.headers["X-Api-Key"], key_type)
|
||||
key_value = request.headers.get("X-Api-Key") or request.args["api-key"]
|
||||
g.wallet = get_wallet_for_key(key_value, key_type)
|
||||
except KeyError:
|
||||
return jsonify({"message": "`X-Api-Key` header missing."}), HTTPStatus.BAD_REQUEST
|
||||
return (
|
||||
jsonify({"message": "`X-Api-Key` header missing."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not g.wallet:
|
||||
return jsonify({"message": "Wrong keys."}), HTTPStatus.UNAUTHORIZED
|
||||
|
||||
return view(**kwargs)
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@@ -31,17 +35,24 @@ def api_check_wallet_key(key_type: str = "invoice"):
|
||||
def api_validate_post_request(*, schema: dict):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
async def wrapped_view(**kwargs):
|
||||
if "application/json" not in request.headers["Content-Type"]:
|
||||
return jsonify({"message": "Content-Type must be `application/json`."}), HTTPStatus.BAD_REQUEST
|
||||
return (
|
||||
jsonify({"message": "Content-Type must be `application/json`."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
v = Validator(schema)
|
||||
g.data = {key: (request.json[key] if key in request.json else None) for key in schema.keys()}
|
||||
data = await request.get_json()
|
||||
g.data = {key: data[key] for key in schema.keys() if key in data}
|
||||
|
||||
if not v.validate(g.data):
|
||||
return jsonify({"message": f"Errors in request data: {v.errors}"}), HTTPStatus.BAD_REQUEST
|
||||
return (
|
||||
jsonify({"message": f"Errors in request data: {v.errors}"}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
return view(**kwargs)
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@@ -51,14 +62,13 @@ def api_validate_post_request(*, schema: dict):
|
||||
def check_user_exists(param: str = "usr"):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
async def wrapped_view(**kwargs):
|
||||
g.user = get_user(request.args.get(param, type=str)) or abort(HTTPStatus.NOT_FOUND, "User does not exist.")
|
||||
allowed_users = getenv("LNBITS_ALLOWED_USERS", "all")
|
||||
|
||||
if allowed_users != "all" and g.user.id not in allowed_users.split(","):
|
||||
abort(HTTPStatus.UNAUTHORIZED, f"User not authorized.")
|
||||
if LNBITS_ALLOWED_USERS and g.user.id not in LNBITS_ALLOWED_USERS:
|
||||
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
|
||||
|
||||
return view(**kwargs)
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@@ -68,7 +78,7 @@ def check_user_exists(param: str = "usr"):
|
||||
def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
async def wrapped_view(**kwargs):
|
||||
query_params = {param: request.args.get(param, type=str) for param in params}
|
||||
|
||||
for param, value in query_params.items():
|
||||
@@ -81,7 +91,7 @@ def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = Fals
|
||||
except ValueError:
|
||||
abort(HTTPStatus.BAD_REQUEST, f"`{param}` is not a valid UUID.")
|
||||
|
||||
return view(**kwargs)
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
amilk_ext: Blueprint = Blueprint("amilk", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
@@ -9,7 +9,7 @@ from .models import AMilk
|
||||
|
||||
def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk:
|
||||
with open_ext_db("amilk") as db:
|
||||
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
|
||||
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO amilks (id, wallet, lnurl, atime, amount)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
|
||||
def m001_initial(db):
|
||||
"""
|
||||
Initial amilks table.
|
||||
"""
|
||||
db.execute("""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS amilks (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
@@ -13,9 +11,5 @@ def m001_initial(db):
|
||||
atime INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
|
||||
def migrate():
|
||||
with open_ext_db("amilk") as db:
|
||||
m001_initial(db)
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -68,9 +68,7 @@
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "amilk/_api_docs.html" %}
|
||||
</q-list>
|
||||
<q-list> {% include "amilk/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import g, abort, render_template
|
||||
from quart import g, abort, render_template
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
@@ -10,12 +10,11 @@ from .crud import get_amilk
|
||||
@amilk_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
def index():
|
||||
return render_template("amilk/index.html", user=g.user)
|
||||
async def index():
|
||||
return await render_template("amilk/index.html", user=g.user)
|
||||
|
||||
|
||||
@amilk_ext.route("/<amilk_id>")
|
||||
def wall(amilk_id):
|
||||
async def wall(amilk_id):
|
||||
amilk = get_amilk(amilk_id) or abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.")
|
||||
|
||||
return render_template("amilk/wall.html", amilk=amilk)
|
||||
return await render_template("amilk/wall.html", amilk=amilk)
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
from flask import g, jsonify, request
|
||||
import httpx
|
||||
from quart import g, jsonify, request, abort
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from lnbits.extensions.amilk import amilk_ext
|
||||
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
|
||||
from lnbits.core.services import create_invoice
|
||||
|
||||
from flask import abort, redirect, request, url_for
|
||||
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl
|
||||
from lnurl.exceptions import LnurlException
|
||||
from time import sleep
|
||||
import requests
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
|
||||
from lnbits.extensions.amilk import amilk_ext
|
||||
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_amilks():
|
||||
async def api_amilks():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
@@ -27,7 +25,7 @@ def api_amilks():
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
|
||||
def api_amilkit(amilk_id):
|
||||
async def api_amilkit(amilk_id):
|
||||
milk = get_amilk(amilk_id)
|
||||
memo = milk.id
|
||||
|
||||
@@ -35,44 +33,40 @@ def api_amilkit(amilk_id):
|
||||
withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse)
|
||||
except LnurlException:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
print(withdraw_res.max_sats)
|
||||
|
||||
payment_hash, payment_request = create_invoice(
|
||||
wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo, extra={"tag": "amilk"}
|
||||
)
|
||||
|
||||
try:
|
||||
checking_id, payment_request = create_invoice(wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo)
|
||||
#print(payment_request)
|
||||
except Exception as e:
|
||||
error_message = False, str(e)
|
||||
|
||||
r = requests.get(
|
||||
r = httpx.get(
|
||||
withdraw_res.callback.base,
|
||||
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
|
||||
)
|
||||
|
||||
if not r.ok:
|
||||
|
||||
if r.is_error:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
|
||||
for i in range(10):
|
||||
invoice_status = WALLET.get_invoice_status(checking_id)
|
||||
sleep(i)
|
||||
if not invoice_status.paid:
|
||||
continue
|
||||
invoice_status = check_invoice_status(milk.wallet, payment_hash)
|
||||
if invoice_status.paid:
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
else:
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
break
|
||||
continue
|
||||
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk", methods=["POST"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(schema={
|
||||
"lnurl": {"type": "string", "empty": False, "required": True},
|
||||
"atime": {"type": "integer", "min": 0, "required": True},
|
||||
"amount": {"type": "integer", "min": 0, "required": True},
|
||||
})
|
||||
def api_amilk_create():
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"lnurl": {"type": "string", "empty": False, "required": True},
|
||||
"atime": {"type": "integer", "min": 0, "required": True},
|
||||
"amount": {"type": "integer", "min": 0, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_amilk_create():
|
||||
amilk = create_amilk(wallet_id=g.wallet.id, lnurl=g.data["lnurl"], atime=g.data["atime"], amount=g.data["amount"])
|
||||
|
||||
return jsonify(amilk._asdict()), HTTPStatus.CREATED
|
||||
@@ -80,7 +74,7 @@ def api_amilk_create():
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_amilk_delete(amilk_id):
|
||||
async def api_amilk_delete(amilk_id):
|
||||
amilk = get_amilk(amilk_id)
|
||||
|
||||
if not amilk:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
import requests
|
||||
import httpx
|
||||
from lnbits.db import open_ext_db
|
||||
from lnbits.settings import WALLET
|
||||
from .models import Products, Orders, Indexers
|
||||
import re
|
||||
|
||||
regex = re.compile(
|
||||
r'^(?:http|ftp)s?://' # http:// or https://
|
||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'
|
||||
r'localhost|'
|
||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
|
||||
r'(?::\d+)?'
|
||||
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
|
||||
r"^(?:http|ftp)s?://" # http:// or https://
|
||||
r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|"
|
||||
r"localhost|"
|
||||
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
|
||||
r"(?::\d+)?"
|
||||
r"(?:/?|[/?]\S+)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
###Products
|
||||
|
||||
def create_diagonalleys_product(*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int) -> Products:
|
||||
|
||||
def create_diagonalleys_product(
|
||||
*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int
|
||||
) -> Products:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
product_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
|
||||
product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
|
||||
@@ -63,19 +69,39 @@ def delete_diagonalleys_product(product_id: str) -> None:
|
||||
db.execute("DELETE FROM products WHERE id = ?", (product_id,))
|
||||
|
||||
|
||||
|
||||
|
||||
###Indexers
|
||||
|
||||
def create_diagonalleys_indexer(wallet_id: str, shopname: str, indexeraddress: str, shippingzone1: str, shippingzone2: str, zone1cost: int, zone2cost: int, email: str) -> Indexers:
|
||||
|
||||
def create_diagonalleys_indexer(
|
||||
wallet_id: str,
|
||||
shopname: str,
|
||||
indexeraddress: str,
|
||||
shippingzone1: str,
|
||||
shippingzone2: str,
|
||||
zone1cost: int,
|
||||
zone2cost: int,
|
||||
email: str,
|
||||
) -> Indexers:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
|
||||
indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(indexer_id, wallet_id, shopname, indexeraddress, False, 0, shippingzone1, shippingzone2, zone1cost, zone2cost, email),
|
||||
(
|
||||
indexer_id,
|
||||
wallet_id,
|
||||
shopname,
|
||||
indexeraddress,
|
||||
False,
|
||||
0,
|
||||
shippingzone1,
|
||||
shippingzone2,
|
||||
zone1cost,
|
||||
zone2cost,
|
||||
email,
|
||||
),
|
||||
)
|
||||
return get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
@@ -94,17 +120,29 @@ def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
roww = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
||||
try:
|
||||
x = requests.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
|
||||
x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
|
||||
if x.status_code == 200:
|
||||
print(x)
|
||||
print("poo")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (True, indexer_id,))
|
||||
db.execute(
|
||||
"UPDATE indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
indexer_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (False, indexer_id,))
|
||||
db.execute(
|
||||
"UPDATE indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
False,
|
||||
indexer_id,
|
||||
),
|
||||
)
|
||||
except:
|
||||
print("An exception occurred")
|
||||
print("An exception occurred")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
||||
return Indexers(**row) if row else None
|
||||
@@ -120,15 +158,27 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
|
||||
|
||||
for r in rows:
|
||||
try:
|
||||
x = requests.get(r["indexeraddress"] + "/" + r["ratingkey"])
|
||||
x = httpx.get(r["indexeraddress"] + "/" + r["ratingkey"])
|
||||
if x.status_code == 200:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (True, r["id"],))
|
||||
db.execute(
|
||||
"UPDATE indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
else:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (False, r["id"],))
|
||||
db.execute(
|
||||
"UPDATE indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
False,
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
except:
|
||||
print("An exception occurred")
|
||||
print("An exception occurred")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
@@ -140,12 +190,24 @@ def delete_diagonalleys_indexer(indexer_id: str) -> None:
|
||||
db.execute("DELETE FROM indexers WHERE id = ?", (indexer_id,))
|
||||
|
||||
|
||||
|
||||
###Orders
|
||||
|
||||
def create_diagonalleys_order(*, productid: str, wallet: str, product: str, quantity: int, shippingzone: str, address: str, email: str, invoiceid: str, paid: bool, shipped: bool) -> Indexers:
|
||||
|
||||
|
||||
def create_diagonalleys_order(
|
||||
*,
|
||||
productid: str,
|
||||
wallet: str,
|
||||
product: str,
|
||||
quantity: int,
|
||||
shippingzone: str,
|
||||
address: str,
|
||||
email: str,
|
||||
invoiceid: str,
|
||||
paid: bool,
|
||||
shipped: bool,
|
||||
) -> Indexers:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
order_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
|
||||
order_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
|
||||
@@ -153,7 +215,7 @@ def create_diagonalleys_order(*, productid: str, wallet: str, product: str, qua
|
||||
""",
|
||||
(order_id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, False, False),
|
||||
)
|
||||
|
||||
|
||||
return get_diagonalleys_order(order_id)
|
||||
|
||||
|
||||
@@ -175,7 +237,13 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
PAID = WALLET.get_invoice_status(r["invoiceid"]).paid
|
||||
if PAID:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("UPDATE orders SET paid = ? WHERE id = ?", (True, r["id"],))
|
||||
db.execute(
|
||||
"UPDATE orders SET paid = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
return [Orders(**row) for row in rows]
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
|
||||
def m001_initial(db):
|
||||
"""
|
||||
Initial products table.
|
||||
"""
|
||||
db.execute("""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
@@ -16,13 +14,14 @@ def m001_initial(db):
|
||||
price INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial indexers table.
|
||||
"""
|
||||
db.execute("""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS indexers (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
@@ -36,13 +35,14 @@ def m001_initial(db):
|
||||
zone2cost INTEGER NOT NULL,
|
||||
email TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial orders table.
|
||||
"""
|
||||
db.execute("""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
productid TEXT NOT NULL,
|
||||
@@ -56,9 +56,5 @@ def m001_initial(db):
|
||||
paid BOOLEAN NOT NULL,
|
||||
shipped BOOLEAN NOT NULL
|
||||
);
|
||||
""")
|
||||
|
||||
def migrate():
|
||||
with open_ext_db("diagonalley") as db:
|
||||
m001_initial(db)
|
||||
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class Indexers(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
@@ -13,6 +14,7 @@ class Indexers(NamedTuple):
|
||||
zone2cost: int
|
||||
email: str
|
||||
|
||||
|
||||
class Products(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
@@ -23,6 +25,7 @@ class Products(NamedTuple):
|
||||
price: int
|
||||
quantity: int
|
||||
|
||||
|
||||
class Orders(NamedTuple):
|
||||
id: str
|
||||
productid: str
|
||||
@@ -35,5 +38,3 @@ class Orders(NamedTuple):
|
||||
invoiceid: str
|
||||
paid: bool
|
||||
shipped: bool
|
||||
|
||||
|
||||
|
||||
@@ -59,9 +59,7 @@
|
||||
:href="props.row.wallet"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip>
|
||||
Link to pass to stall indexer
|
||||
</q-tooltip>
|
||||
<q-tooltip> Link to pass to stall indexer </q-tooltip>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
@@ -194,9 +192,7 @@
|
||||
icon="add_shopping_cart"
|
||||
color="green"
|
||||
>
|
||||
<q-tooltip>
|
||||
Product shipped?
|
||||
</q-tooltip>
|
||||
<q-tooltip> Product shipped? </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
@@ -224,15 +220,13 @@
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "diagonalley/_api_docs.html" %}
|
||||
</q-list>
|
||||
<q-list> {% include "diagonalley/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="productDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px;">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendProductFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
@@ -314,7 +308,7 @@
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="indexerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px;">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendIndexerFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import json
|
||||
|
||||
from flask import g, abort, render_template, jsonify
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.extensions.diagonalley import diagonalley_ext
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
|
||||
@diagonalley_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
def index():
|
||||
|
||||
return render_template("diagonalley/index.html", user=g.user)
|
||||
async def index():
|
||||
return await render_template("diagonalley/index.html", user=g.user)
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
from flask import g, jsonify, request
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from lnbits.extensions.diagonalley import diagonalley_ext
|
||||
from .crud import create_diagonalleys_product,get_diagonalleys_product,get_diagonalleys_products,delete_diagonalleys_product,create_diagonalleys_indexer,update_diagonalleys_indexer,get_diagonalleys_indexer,get_diagonalleys_indexers,delete_diagonalleys_indexer,create_diagonalleys_order,get_diagonalleys_order,get_diagonalleys_orders,delete_diagonalleys_order
|
||||
from .crud import (
|
||||
create_diagonalleys_product,
|
||||
get_diagonalleys_product,
|
||||
get_diagonalleys_products,
|
||||
delete_diagonalleys_product,
|
||||
create_diagonalleys_indexer,
|
||||
update_diagonalleys_indexer,
|
||||
get_diagonalleys_indexer,
|
||||
get_diagonalleys_indexers,
|
||||
delete_diagonalleys_indexer,
|
||||
create_diagonalleys_order,
|
||||
get_diagonalleys_order,
|
||||
get_diagonalleys_orders,
|
||||
update_diagonalleys_product,
|
||||
)
|
||||
from lnbits.core.services import create_invoice
|
||||
from base64 import urlsafe_b64encode
|
||||
from uuid import uuid4
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
###Products
|
||||
### Products
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
def api_diagonalley_products():
|
||||
async def api_diagonalley_products():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
@@ -27,15 +42,17 @@ def api_diagonalley_products():
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"])
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/products<product_id>", methods=["PUT"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
@api_validate_post_request(schema={
|
||||
"product": {"type": "string", "empty": False, "required": True},
|
||||
"categories": {"type": "string", "empty": False, "required": True},
|
||||
"description": {"type": "string", "empty": False, "required": True},
|
||||
"image": {"type": "string", "empty": False, "required": True},
|
||||
"price": {"type": "integer", "min": 0, "required": True},
|
||||
"quantity": {"type": "integer", "min": 0, "required": True}
|
||||
})
|
||||
def api_diagonalley_product_create(product_id=None):
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"product": {"type": "string", "empty": False, "required": True},
|
||||
"categories": {"type": "string", "empty": False, "required": True},
|
||||
"description": {"type": "string", "empty": False, "required": True},
|
||||
"image": {"type": "string", "empty": False, "required": True},
|
||||
"price": {"type": "integer", "min": 0, "required": True},
|
||||
"quantity": {"type": "integer", "min": 0, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_diagonalley_product_create(product_id=None):
|
||||
|
||||
if product_id:
|
||||
product = get_diagonalleys_indexer(product_id)
|
||||
@@ -53,10 +70,9 @@ def api_diagonalley_product_create(product_id=None):
|
||||
return jsonify(product._asdict()), HTTPStatus.OK if product_id else HTTPStatus.CREATED
|
||||
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/products/<product_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
def api_diagonalley_products_delete(product_id):
|
||||
async def api_diagonalley_products_delete(product_id):
|
||||
product = get_diagonalleys_product(product_id)
|
||||
|
||||
if not product:
|
||||
@@ -70,12 +86,12 @@ def api_diagonalley_products_delete(product_id):
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
|
||||
###Indexers
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
def api_diagonalley_indexers():
|
||||
async def api_diagonalley_indexers():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
@@ -87,16 +103,18 @@ def api_diagonalley_indexers():
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"])
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/indexers<indexer_id>", methods=["PUT"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
@api_validate_post_request(schema={
|
||||
"shopname": {"type": "string", "empty": False, "required": True},
|
||||
"indexeraddress": {"type": "string", "empty": False, "required": True},
|
||||
"shippingzone1": {"type": "string", "empty": False, "required": True},
|
||||
"shippingzone2": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
"zone1cost": {"type": "integer", "min": 0, "required": True},
|
||||
"zone2cost": {"type": "integer", "min": 0, "required": True}
|
||||
})
|
||||
def api_diagonalley_indexer_create(indexer_id=None):
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"shopname": {"type": "string", "empty": False, "required": True},
|
||||
"indexeraddress": {"type": "string", "empty": False, "required": True},
|
||||
"shippingzone1": {"type": "string", "empty": False, "required": True},
|
||||
"shippingzone2": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
"zone1cost": {"type": "integer", "min": 0, "required": True},
|
||||
"zone2cost": {"type": "integer", "min": 0, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_diagonalley_indexer_create(indexer_id=None):
|
||||
|
||||
if indexer_id:
|
||||
indexer = get_diagonalleys_indexer(indexer_id)
|
||||
@@ -116,7 +134,7 @@ def api_diagonalley_indexer_create(indexer_id=None):
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/indexers/<indexer_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
def api_diagonalley_indexer_delete(indexer_id):
|
||||
async def api_diagonalley_indexer_delete(indexer_id):
|
||||
indexer = get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
if not indexer:
|
||||
@@ -132,9 +150,10 @@ def api_diagonalley_indexer_delete(indexer_id):
|
||||
|
||||
###Orders
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
def api_diagonalley_orders():
|
||||
async def api_diagonalley_orders():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
@@ -145,21 +164,23 @@ def api_diagonalley_orders():
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
@api_validate_post_request(schema={
|
||||
"id": {"type": "string", "empty": False, "required": True},
|
||||
"address": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
"quantity": {"type": "integer", "empty": False, "required": True},
|
||||
"shippingzone": {"type": "integer", "empty": False, "required": True},
|
||||
})
|
||||
def api_diagonalley_order_create():
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"id": {"type": "string", "empty": False, "required": True},
|
||||
"address": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
"quantity": {"type": "integer", "empty": False, "required": True},
|
||||
"shippingzone": {"type": "integer", "empty": False, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_diagonalley_order_create():
|
||||
order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data)
|
||||
return jsonify(order._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders/<order_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
def api_diagonalley_order_delete(order_id):
|
||||
async def api_diagonalley_order_delete(order_id):
|
||||
order = get_diagonalleys_order(order_id)
|
||||
|
||||
if not order:
|
||||
@@ -175,17 +196,29 @@ def api_diagonalley_order_delete(order_id):
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders/paid/<order_id>", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
def api_diagonalleys_order_paid(order_id):
|
||||
async def api_diagonalleys_order_paid(order_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("UPDATE orders SET paid = ? WHERE id = ?", (True, order_id,))
|
||||
db.execute(
|
||||
"UPDATE orders SET paid = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
order_id,
|
||||
),
|
||||
)
|
||||
return "", HTTPStatus.OK
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders/shipped/<order_id>", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
def api_diagonalleys_order_shipped(order_id):
|
||||
async def api_diagonalleys_order_shipped(order_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("UPDATE orders SET shipped = ? WHERE id = ?", (True, order_id,))
|
||||
db.execute(
|
||||
"UPDATE orders SET shipped = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
order_id,
|
||||
),
|
||||
)
|
||||
order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
|
||||
|
||||
return jsonify([order._asdict() for order in get_diagonalleys_orders(order["wallet"])]), HTTPStatus.OK
|
||||
@@ -193,8 +226,9 @@ def api_diagonalleys_order_shipped(order_id):
|
||||
|
||||
###List products based on indexer id
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"])
|
||||
def api_diagonalleys_stall_products(indexer_id):
|
||||
async def api_diagonalleys_stall_products(indexer_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
||||
print(rows[1])
|
||||
@@ -207,26 +241,32 @@ def api_diagonalleys_stall_products(indexer_id):
|
||||
|
||||
return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), HTTPStatus.OK
|
||||
|
||||
|
||||
###Check a product has been shipped
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"])
|
||||
def api_diagonalleys_stall_checkshipped(checking_id):
|
||||
async def api_diagonalleys_stall_checkshipped(checking_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,))
|
||||
|
||||
return jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK
|
||||
|
||||
|
||||
###Place order
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/stall/order/<indexer_id>", methods=["POST"])
|
||||
@api_validate_post_request(schema={
|
||||
"id": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
"address": {"type": "string", "empty": False, "required": True},
|
||||
"quantity": {"type": "integer", "empty": False, "required": True},
|
||||
"shippingzone": {"type": "integer", "empty": False, "required": True},
|
||||
})
|
||||
def api_diagonalley_stall_order(indexer_id):
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"id": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
"address": {"type": "string", "empty": False, "required": True},
|
||||
"quantity": {"type": "integer", "empty": False, "required": True},
|
||||
"shippingzone": {"type": "integer", "empty": False, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_diagonalley_stall_order(indexer_id):
|
||||
product = get_diagonalleys_product(g.data["id"])
|
||||
shipping = get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
@@ -235,14 +275,28 @@ def api_diagonalley_stall_order(indexer_id):
|
||||
else:
|
||||
shippingcost = shipping.zone2cost
|
||||
|
||||
checking_id, payment_request = create_invoice(wallet_id=product.wallet, amount=shippingcost + (g.data["quantity"] * product.price), memo=g.data["id"])
|
||||
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
|
||||
checking_id, payment_request = create_invoice(
|
||||
wallet_id=product.wallet, amount=shippingcost + (g.data["quantity"] * product.price), memo=g.data["id"]
|
||||
)
|
||||
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(selling_id ,g.data["id"] , product.wallet, product.product, g.data["quantity"], g.data["shippingzone"], g.data["address"], g.data["email"], checking_id, False, False),
|
||||
(
|
||||
selling_id,
|
||||
g.data["id"],
|
||||
product.wallet,
|
||||
product.product,
|
||||
g.data["quantity"],
|
||||
g.data["shippingzone"],
|
||||
g.data["address"],
|
||||
g.data["email"],
|
||||
checking_id,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
)
|
||||
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
events_ext: Blueprint = Blueprint("events", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
@@ -9,32 +9,33 @@ from .models import Tickets, Events
|
||||
#######TICKETS########
|
||||
|
||||
|
||||
def create_ticket(checking_id: str, wallet: str, event: str, name: str, email: str) -> Tickets:
|
||||
def create_ticket(payment_hash: str, wallet: str, event: str, name: str, email: str) -> Tickets:
|
||||
with open_ext_db("events") as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO ticket (id, wallet, event, name, email, registered, paid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(checking_id, wallet, event, name, email, False, False),
|
||||
(payment_hash, wallet, event, name, email, False, False),
|
||||
)
|
||||
|
||||
return get_ticket(checking_id)
|
||||
return get_ticket(payment_hash)
|
||||
|
||||
def update_ticket(paid: bool, checking_id: str) -> Tickets:
|
||||
|
||||
def update_ticket(paid: bool, payment_hash: str) -> Tickets:
|
||||
with open_ext_db("events") as db:
|
||||
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (checking_id,))
|
||||
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
|
||||
if row[6] == True:
|
||||
return get_ticket(checking_id)
|
||||
return get_ticket(payment_hash)
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE ticket
|
||||
SET paid = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(paid, checking_id),
|
||||
(paid, payment_hash),
|
||||
)
|
||||
|
||||
|
||||
eventdata = get_event(row[2])
|
||||
sold = eventdata.sold + 1
|
||||
amount_tickets = eventdata.amount_tickets - 1
|
||||
@@ -46,12 +47,12 @@ def update_ticket(paid: bool, checking_id: str) -> Tickets:
|
||||
""",
|
||||
(sold, amount_tickets, row[2]),
|
||||
)
|
||||
return get_ticket(checking_id)
|
||||
return get_ticket(payment_hash)
|
||||
|
||||
|
||||
def get_ticket(checking_id: str) -> Optional[Tickets]:
|
||||
def get_ticket(payment_hash: str) -> Optional[Tickets]:
|
||||
with open_ext_db("events") as db:
|
||||
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (checking_id,))
|
||||
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
|
||||
|
||||
return Tickets(**row) if row else None
|
||||
|
||||
@@ -67,16 +68,25 @@ def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_ticket(checking_id: str) -> None:
|
||||
def delete_ticket(payment_hash: str) -> None:
|
||||
with open_ext_db("events") as db:
|
||||
db.execute("DELETE FROM ticket WHERE id = ?", (checking_id,))
|
||||
|
||||
db.execute("DELETE FROM ticket WHERE id = ?", (payment_hash,))
|
||||
|
||||
|
||||
########EVENTS#########
|
||||
|
||||
|
||||
def create_event(*, wallet: str, name: str, info: str, closing_date: str, event_start_date: str, event_end_date: str, amount_tickets: int, price_per_ticket: int) -> Events:
|
||||
def create_event(
|
||||
*,
|
||||
wallet: str,
|
||||
name: str,
|
||||
info: str,
|
||||
closing_date: str,
|
||||
event_start_date: str,
|
||||
event_end_date: str,
|
||||
amount_tickets: int,
|
||||
price_per_ticket: int,
|
||||
) -> Events:
|
||||
with open_ext_db("events") as db:
|
||||
event_id = urlsafe_short_hash()
|
||||
db.execute(
|
||||
@@ -84,12 +94,24 @@ def create_event(*, wallet: str, name: str, info: str, closing_date: str, event_
|
||||
INSERT INTO events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(event_id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, 0),
|
||||
(
|
||||
event_id,
|
||||
wallet,
|
||||
name,
|
||||
info,
|
||||
closing_date,
|
||||
event_start_date,
|
||||
event_end_date,
|
||||
amount_tickets,
|
||||
price_per_ticket,
|
||||
0,
|
||||
),
|
||||
)
|
||||
print(event_id)
|
||||
|
||||
return get_event(event_id)
|
||||
|
||||
|
||||
def update_event(event_id: str, **kwargs) -> Events:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
with open_ext_db("events") as db:
|
||||
@@ -122,8 +144,10 @@ def delete_event(event_id: str) -> None:
|
||||
with open_ext_db("events") as db:
|
||||
db.execute("DELETE FROM events WHERE id = ?", (event_id,))
|
||||
|
||||
|
||||
########EVENTTICKETS#########
|
||||
|
||||
|
||||
def get_event_tickets(event_id: str, wallet_id: str) -> Tickets:
|
||||
|
||||
with open_ext_db("events") as db:
|
||||
@@ -132,6 +156,7 @@ def get_event_tickets(event_id: str, wallet_id: str) -> Tickets:
|
||||
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
|
||||
def reg_ticket(ticket_id: str) -> Tickets:
|
||||
with open_ext_db("events") as db:
|
||||
db.execute("UPDATE ticket SET registered = ? WHERE id = ?", (True, ticket_id))
|
||||
@@ -139,4 +164,4 @@ def reg_ticket(ticket_id: str) -> Tickets:
|
||||
print(ticket[1])
|
||||
rows = db.fetchall("SELECT * FROM ticket WHERE event = ?", (ticket[1],))
|
||||
|
||||
return [Tickets(**row) for row in rows]
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
def m001_initial(db):
|
||||
|
||||
db.execute(
|
||||
@@ -51,7 +49,7 @@ def m002_changed(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
for row in [list(row) for row in db.fetchall("SELECT * FROM tickets")]:
|
||||
usescsv = ""
|
||||
|
||||
@@ -60,7 +58,7 @@ def m002_changed(db):
|
||||
usescsv += "," + str(i + 1)
|
||||
else:
|
||||
usescsv += "," + str(1)
|
||||
usescsv = usescsv[1:]
|
||||
usescsv = usescsv[1:]
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO ticket (
|
||||
@@ -75,19 +73,13 @@ def m002_changed(db):
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
row[3],
|
||||
row[4],
|
||||
row[5],
|
||||
row[4],
|
||||
row[5],
|
||||
True,
|
||||
),
|
||||
)
|
||||
db.execute("DROP TABLE tickets")
|
||||
|
||||
def migrate():
|
||||
with open_ext_db("events") as db:
|
||||
m001_initial(db)
|
||||
m002_changed(db)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class Events(NamedTuple):
|
||||
sold: int
|
||||
time: int
|
||||
|
||||
|
||||
class Tickets(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
@@ -22,4 +23,4 @@ class Tickets(NamedTuple):
|
||||
email: str
|
||||
registered: bool
|
||||
paid: bool
|
||||
time: int
|
||||
time: int
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
label="Your email "
|
||||
></q-input>
|
||||
|
||||
<p>{% raw %}{{amountWords}}{% endraw %}</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
@@ -82,7 +81,6 @@
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
<script>
|
||||
console.log('{{ form_costpword }}')
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
@@ -144,7 +142,7 @@
|
||||
)
|
||||
.then(function (response) {
|
||||
self.paymentReq = response.data.payment_request
|
||||
self.paymentCheck = response.data.checking_id
|
||||
self.paymentCheck = response.data.payment_hash
|
||||
|
||||
dismissMsg = self.$q.notify({
|
||||
timeout: 0,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<q-icon
|
||||
name="warning"
|
||||
class="text-grey"
|
||||
style="font-size: 20rem;"
|
||||
style="font-size: 20rem"
|
||||
></q-icon>
|
||||
|
||||
<h5 class="q-my-none">{{ event_error }}</h5>
|
||||
|
||||
@@ -168,9 +168,7 @@
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "events/_api_docs.html" %}
|
||||
</q-list>
|
||||
<q-list> {% include "events/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
@@ -78,24 +78,7 @@
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block styles %}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
href="{{ url_for('static', filename='vendor/vue-qrcode-reader@2.2.0/vue-qrcode-reader.min.css') }}"
|
||||
/>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
{% assets filters='rjsmin', output='__bundle__/core/chart.js',
|
||||
'vendor/moment@2.25.1/moment.min.js', 'vendor/chart.js@2.9.3/chart.min.js' %}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{% endassets %} {% assets filters='rjsmin', output='__bundle__/core/wallet.js',
|
||||
'vendor/bolt11/utils.js', 'vendor/bolt11/decoder.js',
|
||||
'vendor/vue-qrcode-reader@2.2.0/vue-qrcode-reader.min.js' %}
|
||||
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
|
||||
{% endassets %}
|
||||
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
@@ -157,7 +140,7 @@
|
||||
message: 'Registered!'
|
||||
})
|
||||
setTimeout(function () {
|
||||
window.location.reload()
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
new Vue({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import g, abort, render_template
|
||||
from quart import g, abort, render_template
|
||||
from datetime import date, datetime
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
@@ -11,35 +11,45 @@ from .crud import get_ticket, get_event
|
||||
@events_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
def index():
|
||||
return render_template("events/index.html", user=g.user)
|
||||
async def index():
|
||||
return await render_template("events/index.html", user=g.user)
|
||||
|
||||
|
||||
@events_ext.route("/<event_id>")
|
||||
def display(event_id):
|
||||
async def display(event_id):
|
||||
event = get_event(event_id) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
if event.amount_tickets < 1:
|
||||
return render_template("events/error.html", event_name=event.name, event_error="Sorry, tickets are sold out :(")
|
||||
datetime_object = datetime.strptime(event.closing_date, '%Y-%m-%d').date()
|
||||
return await render_template(
|
||||
"events/error.html", event_name=event.name, event_error="Sorry, tickets are sold out :("
|
||||
)
|
||||
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date()
|
||||
if date.today() > datetime_object:
|
||||
return render_template("events/error.html", event_name=event.name, event_error="Sorry, ticket closing date has passed :(")
|
||||
return await render_template(
|
||||
"events/error.html", event_name=event.name, event_error="Sorry, ticket closing date has passed :("
|
||||
)
|
||||
|
||||
|
||||
return render_template("events/display.html", event_id=event_id, event_name=event.name, event_info=event.info, event_price=event.price_per_ticket)
|
||||
return await render_template(
|
||||
"events/display.html",
|
||||
event_id=event_id,
|
||||
event_name=event.name,
|
||||
event_info=event.info,
|
||||
event_price=event.price_per_ticket,
|
||||
)
|
||||
|
||||
|
||||
@events_ext.route("/ticket/<ticket_id>")
|
||||
def ticket(ticket_id):
|
||||
async def ticket(ticket_id):
|
||||
ticket = get_ticket(ticket_id) or abort(HTTPStatus.NOT_FOUND, "Ticket does not exist.")
|
||||
event = get_event(ticket.event) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
return render_template("events/ticket.html", ticket_id=ticket_id, ticket_name=event.name, ticket_info=event.info)
|
||||
return await render_template(
|
||||
"events/ticket.html", ticket_id=ticket_id, ticket_name=event.name, ticket_info=event.info
|
||||
)
|
||||
|
||||
|
||||
@events_ext.route("/register/<event_id>")
|
||||
def register(event_id):
|
||||
async def register(event_id):
|
||||
event = get_event(event_id) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
|
||||
return render_template("events/register.html", event_id=event_id, event_name=event.name, wallet_id=event.wallet)
|
||||
|
||||
|
||||
|
||||
return await render_template(
|
||||
"events/register.html", event_id=event_id, event_name=event.name, wallet_id=event.wallet
|
||||
)
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
from flask import g, jsonify, request
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
from lnbits.extensions.events import events_ext
|
||||
from .crud import create_ticket, update_ticket, get_ticket, get_tickets, delete_ticket, create_event, update_event, get_event, get_events, delete_event, get_event_tickets, reg_ticket
|
||||
from .crud import (
|
||||
create_ticket,
|
||||
update_ticket,
|
||||
get_ticket,
|
||||
get_tickets,
|
||||
delete_ticket,
|
||||
create_event,
|
||||
update_event,
|
||||
get_event,
|
||||
get_events,
|
||||
delete_event,
|
||||
get_event_tickets,
|
||||
reg_ticket,
|
||||
)
|
||||
|
||||
|
||||
#########Events##########
|
||||
@@ -15,7 +27,7 @@ from .crud import create_ticket, update_ticket, get_ticket, get_tickets, delete_
|
||||
|
||||
@events_ext.route("/api/v1/events", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_events():
|
||||
async def api_events():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
@@ -36,10 +48,10 @@ def api_events():
|
||||
"event_start_date": {"type": "string", "empty": False, "required": True},
|
||||
"event_end_date": {"type": "string", "empty": False, "required": True},
|
||||
"amount_tickets": {"type": "integer", "min": 0, "required": True},
|
||||
"price_per_ticket": {"type": "integer", "min": 0, "required": True}
|
||||
"price_per_ticket": {"type": "integer", "min": 0, "required": True},
|
||||
}
|
||||
)
|
||||
def api_event_create(event_id=None):
|
||||
async def api_event_create(event_id=None):
|
||||
if event_id:
|
||||
event = get_event(event_id)
|
||||
print(g.data)
|
||||
@@ -59,7 +71,7 @@ def api_event_create(event_id=None):
|
||||
|
||||
@events_ext.route("/api/v1/events/<event_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_form_delete(event_id):
|
||||
async def api_form_delete(event_id):
|
||||
event = get_event(event_id)
|
||||
|
||||
if not event:
|
||||
@@ -75,9 +87,10 @@ def api_form_delete(event_id):
|
||||
|
||||
#########Tickets##########
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/tickets", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_tickets():
|
||||
async def api_tickets():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
@@ -90,42 +103,41 @@ def api_tickets():
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True}
|
||||
})
|
||||
def api_ticket_make_ticket(event_id, sats):
|
||||
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_ticket_make_ticket(event_id, sats):
|
||||
event = get_event(event_id)
|
||||
|
||||
if not event:
|
||||
return jsonify({"message": "LNTicket does not exist."}), HTTPStatus.NOT_FOUND
|
||||
return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND
|
||||
try:
|
||||
checking_id, payment_request = create_invoice(
|
||||
wallet_id=event.wallet, amount=int(sats), memo=f"#lnticket {event_id}"
|
||||
payment_hash, payment_request = create_invoice(
|
||||
wallet_id=event.wallet, amount=int(sats), memo=f"{event_id}", extra={"tag": "events"}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
ticket = create_ticket(checking_id=checking_id, wallet=event.wallet, event=event_id, **g.data)
|
||||
ticket = create_ticket(payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data)
|
||||
|
||||
if not ticket:
|
||||
return jsonify({"message": "LNTicket could not be fetched."}), HTTPStatus.NOT_FOUND
|
||||
return jsonify({"message": "Event could not be fetched."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
|
||||
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/tickets/<checking_id>", methods=["GET"])
|
||||
def api_ticket_send_ticket(checking_id):
|
||||
theticket = get_ticket(checking_id)
|
||||
@events_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
|
||||
async def api_ticket_send_ticket(payment_hash):
|
||||
ticket = get_ticket(payment_hash)
|
||||
try:
|
||||
is_paid = not WALLET.get_invoice_status(checking_id).pending
|
||||
is_paid = not check_invoice_status(ticket.wallet, payment_hash).pending
|
||||
except Exception:
|
||||
return jsonify({"message": "Not paid."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if is_paid:
|
||||
wallet = get_wallet(theticket.wallet)
|
||||
payment = wallet.get_payment(checking_id)
|
||||
wallet = get_wallet(ticket.wallet)
|
||||
payment = wallet.get_payment(payment_hash)
|
||||
payment.set_pending(False)
|
||||
ticket = update_ticket(paid=True, checking_id=checking_id)
|
||||
ticket = update_ticket(paid=True, payment_hash=payment_hash)
|
||||
|
||||
return jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK
|
||||
|
||||
@@ -134,7 +146,7 @@ def api_ticket_send_ticket(checking_id):
|
||||
|
||||
@events_ext.route("/api/v1/tickets/<ticket_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_ticket_delete(ticket_id):
|
||||
async def api_ticket_delete(ticket_id):
|
||||
ticket = get_ticket(ticket_id)
|
||||
|
||||
if not ticket:
|
||||
@@ -150,14 +162,19 @@ def api_ticket_delete(ticket_id):
|
||||
|
||||
#########EventTickets##########
|
||||
|
||||
@events_ext.route("/api/v1/eventtickets/<wallet_id>/<event_id>", methods=["GET"])
|
||||
def api_event_tickets(wallet_id, event_id):
|
||||
|
||||
return jsonify([ticket._asdict() for ticket in get_event_tickets(wallet_id=wallet_id, event_id=event_id)]), HTTPStatus.OK
|
||||
@events_ext.route("/api/v1/eventtickets/<wallet_id>/<event_id>", methods=["GET"])
|
||||
async def api_event_tickets(wallet_id, event_id):
|
||||
|
||||
return (
|
||||
jsonify([ticket._asdict() for ticket in get_event_tickets(wallet_id=wallet_id, event_id=event_id)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/register/ticket/<ticket_id>", methods=["GET"])
|
||||
def api_event_register_ticket(ticket_id):
|
||||
|
||||
async def api_event_register_ticket(ticket_id):
|
||||
|
||||
ticket = get_ticket(ticket_id)
|
||||
|
||||
if not ticket:
|
||||
@@ -166,6 +183,4 @@ def api_event_register_ticket(ticket_id):
|
||||
if ticket.registered == True:
|
||||
return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN
|
||||
|
||||
|
||||
return jsonify([ticket._asdict() for ticket in reg_ticket(ticket_id)]), HTTPStatus.OK
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
example_ext: Blueprint = Blueprint("example", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
|
||||
def migrate():
|
||||
print("pending")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import g, render_template
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.extensions.example import example_ext
|
||||
@@ -7,5 +7,5 @@ from lnbits.extensions.example import example_ext
|
||||
@example_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
def index():
|
||||
return render_template("example/index.html", user=g.user)
|
||||
async def index():
|
||||
return await render_template("example/index.html", user=g.user)
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
# add your dependencies here
|
||||
|
||||
# import json
|
||||
# import requests
|
||||
# import httpx
|
||||
# (use httpx just like requests, except instead of response.ok there's only the
|
||||
# response.is_error that is its inverse)
|
||||
|
||||
from flask import jsonify
|
||||
from quart import jsonify
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.extensions.example import example_ext
|
||||
@@ -13,8 +15,9 @@ from lnbits.extensions.example import example_ext
|
||||
|
||||
# add your endpoints here
|
||||
|
||||
|
||||
@example_ext.route("/api/v1/tools", methods=["GET"])
|
||||
def api_example():
|
||||
async def api_example():
|
||||
"""Try to add descriptions for others."""
|
||||
tools = [
|
||||
{
|
||||
@@ -31,7 +34,7 @@ def api_example():
|
||||
"name": "Quasar Framework",
|
||||
"url": "https://quasar.dev/",
|
||||
"language": "JavaScript",
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
return jsonify(tools), HTTPStatus.OK
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<h1>lndhub Extension</h1>
|
||||
<h2>*connect to your lnbits wallet from BlueWallet or Zeus*</h2>
|
||||
|
||||
Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/.
|
||||
|
||||
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club.
|
||||
@@ -0,0 +1,8 @@
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
lndhub_ext: Blueprint = Blueprint("lndhub", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "LndHub",
|
||||
"short_description": "Access lnbits from BlueWallet or Zeus.",
|
||||
"icon": "navigation",
|
||||
"contributors": ["fiatjaf"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
from base64 import b64decode
|
||||
from quart import jsonify, g, request
|
||||
from functools import wraps
|
||||
|
||||
from lnbits.core.crud import get_wallet_for_key
|
||||
|
||||
|
||||
def check_wallet(requires_admin=False):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
async def wrapped_view(**kwargs):
|
||||
token = request.headers["Authorization"].split("Bearer ")[1]
|
||||
key_type, key = b64decode(token).decode("utf-8").split(":")
|
||||
|
||||
if requires_admin and key_type != "admin":
|
||||
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
|
||||
|
||||
g.wallet = get_wallet_for_key(key, key_type)
|
||||
if not g.wallet:
|
||||
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
return wrap
|
||||
@@ -0,0 +1,2 @@
|
||||
def migrate():
|
||||
pass
|
||||
@@ -0,0 +1,35 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="info"
|
||||
label="Instructions"
|
||||
default-opened
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
To access an LNbits wallet from a mobile phone,
|
||||
<ol>
|
||||
<li>
|
||||
Install either <a href="https://zeusln.app">Zeus</a> or
|
||||
<a href="https://bluewallet.io/">BlueWallet</a>;
|
||||
</li>
|
||||
<li>
|
||||
Go to <code>Add a wallet / Import wallet</code> on BlueWallet or
|
||||
<code>Settings / Add a new node</code> on Zeus.
|
||||
</li>
|
||||
<li>Select the desired wallet on this page;</li>
|
||||
<li>Scan one of the two QR codes from the mobile wallet.</li>
|
||||
</ol>
|
||||
<ul>
|
||||
<li>
|
||||
<em>Invoice</em> URLs mean the mobile wallet will only have the
|
||||
authorization to read your payments and invoices and generate new
|
||||
invoices.
|
||||
</li>
|
||||
<li>
|
||||
<em>Admin</em> URLs mean the mobile wallet will be able to pay
|
||||
invoices..
|
||||
</li>
|
||||
</ul>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,19 @@
|
||||
<q-expansion-item group="extras" icon="info" label="LndHub info">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
LndHub is a protocol invented by
|
||||
<a href="https://bluewallet.io/">BlueWallet</a> that allows mobile
|
||||
wallets to query payments and balances, generate invoices and make
|
||||
payments from accounts that exist on a server. The protocol is a
|
||||
collection of HTTP endpoints exposed through the internet.
|
||||
</p>
|
||||
<p>
|
||||
For a wallet that supports it, reading a QR code that contains the URL
|
||||
along with secret access credentials should enable access. Currently it
|
||||
is supported by <a href="https://zeusln.app">Zeus</a> and
|
||||
<a href="https://bluewallet.io/">BlueWallet</a>.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %} {% raw %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<div class="row q-col-gutter-md">
|
||||
<q-card
|
||||
v-for="type in ['invoice', 'admin']"
|
||||
v-bind:key="type"
|
||||
class="q-pa-sm q-ma-sm col-5"
|
||||
>
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center">
|
||||
<a :href="selectedWallet[type]">
|
||||
<q-responsive :ratio="1" class="q-mx-sm">
|
||||
<qrcode
|
||||
:value="selectedWallet[type]"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg items-center justify-center">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(selectedWallet[type])"
|
||||
class="text-center q-mb-md"
|
||||
>Copy LndHub {{type}} URL</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="selectedWallet"
|
||||
:options="wallets"
|
||||
label="Select wallet:"
|
||||
>
|
||||
</q-select>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% endraw %}
|
||||
|
||||
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits LndHub extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "lndhub/_instructions.html" %}
|
||||
<q-separator></q-separator>
|
||||
{% include "lndhub/_lndhub.html" %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
var wallets = JSON.parse('{{ g.user.wallets | tojson }}')
|
||||
.map(LNbits.map.wallet)
|
||||
.map(wallet => ({
|
||||
label: wallet.name,
|
||||
admin: `lndhub://admin:${wallet.adminkey}@${location.protocol}//${location.host}/lndhub/ext/`,
|
||||
invoice: `lndhub://invoice:${wallet.inkey}@${location.protocol}//${location.host}/lndhub/ext/`
|
||||
}))
|
||||
|
||||
return {
|
||||
wallets: wallets,
|
||||
selectedWallet: wallets[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
from binascii import unhexlify
|
||||
|
||||
from lnbits.bolt11 import Invoice
|
||||
|
||||
|
||||
def to_buffer(payment_hash: str):
|
||||
return {"type": "Buffer", "data": [b for b in unhexlify(payment_hash)]}
|
||||
|
||||
|
||||
def decoded_as_lndhub(invoice: Invoice):
|
||||
return {
|
||||
"destination": invoice.payee,
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"num_satoshis": invoice.amount_msat / 1000,
|
||||
"timestamp": str(invoice.date),
|
||||
"expiry": str(invoice.expiry),
|
||||
"description": invoice.description,
|
||||
"fallback_addr": "",
|
||||
"cltv_expiry": invoice.min_final_cltv_expiry,
|
||||
"route_hints": "",
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
from quart import render_template, g
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.extensions.lndhub import lndhub_ext
|
||||
|
||||
|
||||
@lndhub_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def lndhub_index():
|
||||
return await render_template("lndhub/index.html", user=g.user)
|
||||
@@ -0,0 +1,202 @@
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from quart import jsonify, g, request
|
||||
|
||||
from lnbits.core.services import pay_invoice, create_invoice
|
||||
from lnbits.core.crud import delete_expired_invoices
|
||||
from lnbits.decorators import api_validate_post_request
|
||||
from lnbits.settings import WALLET
|
||||
from lnbits import bolt11
|
||||
|
||||
from lnbits.extensions.lndhub import lndhub_ext
|
||||
from .decorators import check_wallet
|
||||
from .utils import to_buffer, decoded_as_lndhub
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/getinfo", methods=["GET"])
|
||||
async def lndhub_getinfo():
|
||||
return jsonify({"error": True, "code": 1, "message": "bad auth"})
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/auth", methods=["POST"])
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"login": {"type": "string", "required": True, "excludes": "refresh_token"},
|
||||
"password": {"type": "string", "required": True, "excludes": "refresh_token"},
|
||||
"refresh_token": {"type": "string", "required": True, "excludes": ["login", "password"]},
|
||||
}
|
||||
)
|
||||
async def lndhub_auth():
|
||||
token = (
|
||||
g.data["refresh_token"]
|
||||
if "refresh_token" in g.data and g.data["refresh_token"]
|
||||
else urlsafe_b64encode((g.data["login"] + ":" + g.data["password"]).encode("utf-8")).decode("ascii")
|
||||
)
|
||||
return jsonify({"refresh_token": token, "access_token": token})
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/addinvoice", methods=["POST"])
|
||||
@check_wallet()
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"amt": {"type": "string", "required": True},
|
||||
"memo": {"type": "string", "required": True},
|
||||
"preimage": {"type": "string", "required": False},
|
||||
}
|
||||
)
|
||||
async def lndhub_addinvoice():
|
||||
try:
|
||||
_, pr = create_invoice(
|
||||
wallet_id=g.wallet.id,
|
||||
amount=int(g.data["amt"]),
|
||||
memo=g.data["memo"],
|
||||
extra={"tag": "lndhub"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"code": 7,
|
||||
"message": "Failed to create invoice: " + str(e),
|
||||
}
|
||||
)
|
||||
|
||||
invoice = bolt11.decode(pr)
|
||||
return jsonify(
|
||||
{
|
||||
"pay_req": pr,
|
||||
"payment_request": pr,
|
||||
"add_index": "500",
|
||||
"r_hash": to_buffer(invoice.payment_hash),
|
||||
"hash": invoice.payment_hash,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/payinvoice", methods=["POST"])
|
||||
@check_wallet(requires_admin=True)
|
||||
@api_validate_post_request(schema={"invoice": {"type": "string", "required": True}})
|
||||
async def lndhub_payinvoice():
|
||||
try:
|
||||
pay_invoice(
|
||||
wallet_id=g.wallet.id,
|
||||
payment_request=g.data["invoice"],
|
||||
extra={"tag": "lndhub"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{
|
||||
"error": True,
|
||||
"code": 10,
|
||||
"message": "Payment failed: " + str(e),
|
||||
}
|
||||
)
|
||||
|
||||
invoice: bolt11.Invoice = bolt11.decode(g.data["invoice"])
|
||||
return jsonify(
|
||||
{
|
||||
"payment_error": "",
|
||||
"payment_preimage": "0" * 64,
|
||||
"route": {},
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"decoded": decoded_as_lndhub(invoice),
|
||||
"fee_msat": 0,
|
||||
"type": "paid_invoice",
|
||||
"fee": 0,
|
||||
"value": invoice.amount_msat / 1000,
|
||||
"timestamp": int(time.time()),
|
||||
"memo": invoice.description,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/balance", methods=["GET"])
|
||||
@check_wallet()
|
||||
async def lndhub_balance():
|
||||
return jsonify({"BTC": {"AvailableBalance": g.wallet.balance}})
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/gettxs", methods=["GET"])
|
||||
@check_wallet()
|
||||
async def lndhub_gettxs():
|
||||
for payment in g.wallet.get_payments(
|
||||
complete=False, pending=True, outgoing=True, incoming=False, exclude_uncheckable=True
|
||||
):
|
||||
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
|
||||
|
||||
limit = int(request.args.get("limit", 200))
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"payment_preimage": payment.preimage,
|
||||
"payment_hash": payment.payment_hash,
|
||||
"fee_msat": payment.fee * 1000,
|
||||
"type": "paid_invoice",
|
||||
"fee": payment.fee,
|
||||
"value": int(payment.amount / 1000),
|
||||
"timestamp": payment.time,
|
||||
"memo": payment.memo if not payment.pending else "Payment in transition",
|
||||
}
|
||||
for payment in reversed(
|
||||
g.wallet.get_payments(pending=True, complete=True, outgoing=True, incoming=False)[:limit]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/getuserinvoices", methods=["GET"])
|
||||
@check_wallet()
|
||||
async def lndhub_getuserinvoices():
|
||||
delete_expired_invoices()
|
||||
for invoice in g.wallet.get_payments(
|
||||
complete=False, pending=True, outgoing=False, incoming=True, exclude_uncheckable=True
|
||||
):
|
||||
invoice.set_pending(WALLET.get_invoice_status(invoice.checking_id).pending)
|
||||
|
||||
limit = int(request.args.get("limit", 200))
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"r_hash": to_buffer(invoice.payment_hash),
|
||||
"payment_request": invoice.bolt11,
|
||||
"add_index": "500",
|
||||
"description": invoice.memo,
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"ispaid": not invoice.pending,
|
||||
"amt": int(invoice.amount / 1000),
|
||||
"expire_time": int(time.time() + 1800),
|
||||
"timestamp": invoice.time,
|
||||
"type": "user_invoice",
|
||||
}
|
||||
for invoice in reversed(
|
||||
g.wallet.get_payments(pending=True, complete=True, incoming=True, outgoing=False)[:limit]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/getbtc", methods=["GET"])
|
||||
@check_wallet()
|
||||
async def lndhub_getbtc():
|
||||
"load an address for incoming onchain btc"
|
||||
return jsonify([])
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/getpending", methods=["GET"])
|
||||
@check_wallet()
|
||||
async def lndhub_getpending():
|
||||
"pending onchain transactions"
|
||||
return jsonify([])
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/decodeinvoice", methods=["GET"])
|
||||
async def lndhub_decodeinvoice():
|
||||
invoice = request.args.get("invoice")
|
||||
inv = bolt11.decode(invoice)
|
||||
return jsonify(decoded_as_lndhub(inv))
|
||||
|
||||
|
||||
@lndhub_ext.route("/ext/checkrouteinvoice", methods=["GET"])
|
||||
async def lndhub_checkrouteinvoice():
|
||||
"not implemented on canonical lndhub"
|
||||
pass
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
lnticket_ext: Blueprint = Blueprint("lnticket", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
@@ -6,36 +6,36 @@ from lnbits.helpers import urlsafe_short_hash
|
||||
from .models import Tickets, Forms
|
||||
|
||||
|
||||
|
||||
|
||||
#######TICKETS########
|
||||
|
||||
def create_ticket(checking_id: str, wallet: str, form: str, name: str, email: str, ltext: str, sats: int) -> Tickets:
|
||||
|
||||
def create_ticket(payment_hash: str, wallet: str, form: str, name: str, email: str, ltext: str, sats: int) -> Tickets:
|
||||
with open_ext_db("lnticket") as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO ticket (id, form, email, ltext, name, wallet, sats, paid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(checking_id, form, email, ltext, name, wallet, sats, False),
|
||||
(payment_hash, form, email, ltext, name, wallet, sats, False),
|
||||
)
|
||||
|
||||
return get_ticket(checking_id)
|
||||
return get_ticket(payment_hash)
|
||||
|
||||
def update_ticket(paid: bool, checking_id: str) -> Tickets:
|
||||
|
||||
def update_ticket(paid: bool, payment_hash: str) -> Tickets:
|
||||
with open_ext_db("lnticket") as db:
|
||||
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (checking_id,))
|
||||
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
|
||||
if row[7] == True:
|
||||
return get_ticket(checking_id)
|
||||
return get_ticket(payment_hash)
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE ticket
|
||||
SET paid = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(paid, checking_id),
|
||||
(paid, payment_hash),
|
||||
)
|
||||
|
||||
|
||||
formdata = get_form(row[1])
|
||||
amount = formdata.amountmade + row[7]
|
||||
db.execute(
|
||||
@@ -46,7 +46,8 @@ def update_ticket(paid: bool, checking_id: str) -> Tickets:
|
||||
""",
|
||||
(amount, row[1]),
|
||||
)
|
||||
return get_ticket(checking_id)
|
||||
return get_ticket(payment_hash)
|
||||
|
||||
|
||||
def get_ticket(ticket_id: str) -> Optional[Tickets]:
|
||||
with open_ext_db("lnticket") as db:
|
||||
@@ -71,8 +72,6 @@ def delete_ticket(ticket_id: str) -> None:
|
||||
db.execute("DELETE FROM ticket WHERE id = ?", (ticket_id,))
|
||||
|
||||
|
||||
|
||||
|
||||
########FORMS#########
|
||||
|
||||
|
||||
@@ -84,11 +83,12 @@ def create_form(*, wallet: str, name: str, description: str, costpword: int) ->
|
||||
INSERT INTO forms (id, wallet, name, description, costpword, amountmade)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(form_id, wallet, name, description, costpword, 0 ),
|
||||
(form_id, wallet, name, description, costpword, 0),
|
||||
)
|
||||
|
||||
return get_form(form_id)
|
||||
|
||||
|
||||
def update_form(form_id: str, **kwargs) -> Forms:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
with open_ext_db("lnticket") as db:
|
||||
@@ -118,4 +118,4 @@ def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]:
|
||||
|
||||
def delete_form(form_id: str) -> None:
|
||||
with open_ext_db("lnticket") as db:
|
||||
db.execute("DELETE FROM forms WHERE id = ?", (form_id,))
|
||||
db.execute("DELETE FROM forms WHERE id = ?", (form_id,))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
def m001_initial(db):
|
||||
|
||||
db.execute(
|
||||
@@ -50,7 +48,6 @@ def m002_changed(db):
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
for row in [list(row) for row in db.fetchall("SELECT * FROM tickets")]:
|
||||
usescsv = ""
|
||||
|
||||
@@ -59,7 +56,7 @@ def m002_changed(db):
|
||||
usescsv += "," + str(i + 1)
|
||||
else:
|
||||
usescsv += "," + str(1)
|
||||
usescsv = usescsv[1:]
|
||||
usescsv = usescsv[1:]
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO ticket (
|
||||
@@ -75,20 +72,14 @@ def m002_changed(db):
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
row[3],
|
||||
row[4],
|
||||
row[5],
|
||||
row[6],
|
||||
True,
|
||||
row[4],
|
||||
row[5],
|
||||
row[6],
|
||||
True,
|
||||
),
|
||||
)
|
||||
db.execute("DROP TABLE tickets")
|
||||
|
||||
|
||||
def migrate():
|
||||
with open_ext_db("lnticket") as db:
|
||||
m001_initial(db)
|
||||
m002_changed(db)
|
||||
|
||||
@@ -21,4 +21,3 @@ class Tickets(NamedTuple):
|
||||
sats: int
|
||||
paid: bool
|
||||
time: int
|
||||
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
|
||||
<script>
|
||||
console.log('{{ form_costpword }}')
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
@@ -106,15 +105,15 @@
|
||||
computed: {
|
||||
amountWords() {
|
||||
var regex = /\s+/gi
|
||||
var char = this.formDialog.data.text
|
||||
var nwords = this.formDialog.data.text
|
||||
.trim()
|
||||
.replace(regex, ' ')
|
||||
.split(' ').length
|
||||
this.formDialog.data.sats = char * parseInt('{{ form_costpword }}')
|
||||
if (this.formDialog.data.sats == parseInt('{{ form_costpword }}')) {
|
||||
var sats = nwords * parseInt('{{ form_costpword }}')
|
||||
if (sats === parseInt('{{ form_costpword }}')) {
|
||||
return '0 Sats to pay'
|
||||
} else {
|
||||
return this.formDialog.data.sats + ' Sats to pay'
|
||||
return sats + ' Sats to pay'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -125,7 +124,6 @@
|
||||
this.formDialog.data.name = ''
|
||||
this.formDialog.data.email = ''
|
||||
this.formDialog.data.text = ''
|
||||
this.formDialog.data.sats = 0
|
||||
},
|
||||
|
||||
closeReceiveDialog: function () {
|
||||
@@ -138,21 +136,15 @@
|
||||
Invoice: function () {
|
||||
var self = this
|
||||
axios
|
||||
.post(
|
||||
'/lnticket/api/v1/tickets/' +
|
||||
'{{ form_id }}/' +
|
||||
self.formDialog.data.sats,
|
||||
{
|
||||
form: '{{ form_id }}',
|
||||
name: self.formDialog.data.name,
|
||||
email: self.formDialog.data.email,
|
||||
ltext: self.formDialog.data.text,
|
||||
sats: self.formDialog.data.sats
|
||||
}
|
||||
)
|
||||
.post('/lnticket/api/v1/tickets/{{ form_id }}', {
|
||||
form: '{{ form_id }}',
|
||||
name: self.formDialog.data.name,
|
||||
email: self.formDialog.data.email,
|
||||
ltext: self.formDialog.data.text
|
||||
})
|
||||
.then(function (response) {
|
||||
self.paymentReq = response.data.payment_request
|
||||
self.paymentCheck = response.data.checking_id
|
||||
self.paymentCheck = response.data.payment_hash
|
||||
|
||||
dismissMsg = self.$q.notify({
|
||||
timeout: 0,
|
||||
@@ -175,7 +167,6 @@
|
||||
self.formDialog.data.name = ''
|
||||
self.formDialog.data.email = ''
|
||||
self.formDialog.data.text = ''
|
||||
self.formDialog.data.sats = 0
|
||||
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
|
||||
@@ -157,9 +157,7 @@
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "lnticket/_api_docs.html" %}
|
||||
</q-list>
|
||||
<q-list> {% include "lnticket/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import g, abort, render_template
|
||||
from quart import g, abort, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from http import HTTPStatus
|
||||
@@ -10,13 +10,19 @@ from .crud import get_form
|
||||
@lnticket_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
def index():
|
||||
return render_template("lnticket/index.html", user=g.user)
|
||||
async def index():
|
||||
return await render_template("lnticket/index.html", user=g.user)
|
||||
|
||||
|
||||
@lnticket_ext.route("/<form_id>")
|
||||
def display(form_id):
|
||||
async def display(form_id):
|
||||
form = get_form(form_id) or abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.")
|
||||
print(form.id)
|
||||
|
||||
return render_template("lnticket/display.html", form_id=form.id, form_name=form.name, form_desc=form.description, form_costpword=form.costpword)
|
||||
return await render_template(
|
||||
"lnticket/display.html",
|
||||
form_id=form.id,
|
||||
form_name=form.name,
|
||||
form_desc=form.description,
|
||||
form_costpword=form.costpword,
|
||||
)
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
from flask import g, jsonify, request
|
||||
import re
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet
|
||||
from lnbits.core.services import create_invoice
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
from lnbits.extensions.lnticket import lnticket_ext
|
||||
from .crud import create_ticket, update_ticket, get_ticket, get_tickets, delete_ticket, create_form, update_form, get_form, get_forms, delete_form
|
||||
from .crud import (
|
||||
create_ticket,
|
||||
update_ticket,
|
||||
get_ticket,
|
||||
get_tickets,
|
||||
delete_ticket,
|
||||
create_form,
|
||||
update_form,
|
||||
get_form,
|
||||
get_forms,
|
||||
delete_form,
|
||||
)
|
||||
|
||||
|
||||
#########FORMS##########
|
||||
|
||||
|
||||
@lnticket_ext.route("/api/v1/forms", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_forms():
|
||||
async def api_forms():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
@@ -31,13 +43,12 @@ def api_forms():
|
||||
"wallet": {"type": "string", "empty": False, "required": True},
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"description": {"type": "string", "min": 0, "required": True},
|
||||
"costpword": {"type": "integer", "min": 0, "required": True}
|
||||
"costpword": {"type": "integer", "min": 0, "required": True},
|
||||
}
|
||||
)
|
||||
def api_form_create(form_id=None):
|
||||
async def api_form_create(form_id=None):
|
||||
if form_id:
|
||||
form = get_form(form_id)
|
||||
print(g.data)
|
||||
|
||||
if not form:
|
||||
return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND
|
||||
@@ -53,7 +64,7 @@ def api_form_create(form_id=None):
|
||||
|
||||
@lnticket_ext.route("/api/v1/forms/<form_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_form_delete(form_id):
|
||||
async def api_form_delete(form_id):
|
||||
form = get_form(form_id)
|
||||
|
||||
if not form:
|
||||
@@ -66,11 +77,13 @@ def api_form_delete(form_id):
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
#########tickets##########
|
||||
|
||||
|
||||
@lnticket_ext.route("/api/v1/tickets", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_tickets():
|
||||
async def api_tickets():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
@@ -79,57 +92,60 @@ def api_tickets():
|
||||
return jsonify([form._asdict() for form in get_tickets(wallet_ids)]), HTTPStatus.OK
|
||||
|
||||
|
||||
@lnticket_ext.route("/api/v1/tickets/<form_id>/<sats>", methods=["POST"])
|
||||
@lnticket_ext.route("/api/v1/tickets/<form_id>", methods=["POST"])
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"form": {"type": "string", "empty": False, "required": True},
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": True, "required": True},
|
||||
"ltext": {"type": "string", "empty": False, "required": True},
|
||||
"sats": {"type": "integer", "min": 0, "required": True}
|
||||
})
|
||||
def api_ticket_make_ticket(form_id, sats):
|
||||
|
||||
event = get_form(form_id)
|
||||
|
||||
if not event:
|
||||
}
|
||||
)
|
||||
async def api_ticket_make_ticket(form_id):
|
||||
form = get_form(form_id)
|
||||
if not form:
|
||||
return jsonify({"message": "LNTicket does not exist."}), HTTPStatus.NOT_FOUND
|
||||
try:
|
||||
checking_id, payment_request = create_invoice(
|
||||
wallet_id=event.wallet, amount=int(sats), memo=f"#lnticket {form_id}"
|
||||
nwords = len(re.split(r"\s+", g.data["ltext"]))
|
||||
sats = nwords * form.costpword
|
||||
payment_hash, payment_request = create_invoice(
|
||||
wallet_id=form.wallet,
|
||||
amount=sats,
|
||||
memo=f"ticket with {nwords} words on {form_id}",
|
||||
extra={"tag": "lnticket"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
ticket = create_ticket(checking_id=checking_id, wallet=event.wallet, **g.data)
|
||||
ticket = create_ticket(payment_hash=payment_hash, wallet=form.wallet, sats=sats, **g.data)
|
||||
|
||||
if not ticket:
|
||||
return jsonify({"message": "LNTicket could not be fetched."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
|
||||
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK
|
||||
|
||||
|
||||
@lnticket_ext.route("/api/v1/tickets/<checking_id>", methods=["GET"])
|
||||
def api_ticket_send_ticket(checking_id):
|
||||
theticket = get_ticket(checking_id)
|
||||
@lnticket_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
|
||||
async def api_ticket_send_ticket(payment_hash):
|
||||
ticket = get_ticket(payment_hash)
|
||||
try:
|
||||
is_paid = not WALLET.get_invoice_status(checking_id).pending
|
||||
is_paid = not check_invoice_status(ticket.wallet, payment_hash).pending
|
||||
except Exception:
|
||||
return jsonify({"message": "Not paid."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if is_paid:
|
||||
wallet = get_wallet(theticket.wallet)
|
||||
payment = wallet.get_payment(checking_id)
|
||||
wallet = get_wallet(ticket.wallet)
|
||||
payment = wallet.get_payment(payment_hash)
|
||||
payment.set_pending(False)
|
||||
ticket = update_ticket(paid=True, checking_id=checking_id)
|
||||
|
||||
ticket = update_ticket(paid=True, payment_hash=payment_hash)
|
||||
return jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK
|
||||
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
|
||||
@lnticket_ext.route("/api/v1/tickets/<ticket_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_ticket_delete(ticket_id):
|
||||
async def api_ticket_delete(ticket_id):
|
||||
ticket = get_ticket(ticket_id)
|
||||
|
||||
if not ticket:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# LNURLp
|
||||
@@ -0,0 +1,14 @@
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import register_listeners
|
||||
|
||||
from lnbits.tasks import record_async
|
||||
|
||||
lnurlp_ext.record(record_async(register_listeners))
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "LNURLp",
|
||||
"short_description": "Make reusable LNURL pay links",
|
||||
"icon": "receipt",
|
||||
"contributors": [
|
||||
"arcbtc",
|
||||
"eillarra",
|
||||
"fiatjaf"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.db import open_ext_db
|
||||
from lnbits.core.models import Payment
|
||||
from quart import g
|
||||
|
||||
from .models import PayLink
|
||||
|
||||
|
||||
def create_pay_link(
|
||||
*,
|
||||
wallet_id: str,
|
||||
description: str,
|
||||
min: int,
|
||||
max: int,
|
||||
comment_chars: int = 0,
|
||||
currency: Optional[str] = None,
|
||||
webhook_url: Optional[str] = None,
|
||||
success_text: Optional[str] = None,
|
||||
success_url: Optional[str] = None,
|
||||
) -> Optional[PayLink]:
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO pay_links (
|
||||
wallet,
|
||||
description,
|
||||
min,
|
||||
max,
|
||||
served_meta,
|
||||
served_pr,
|
||||
webhook_url,
|
||||
success_text,
|
||||
success_url,
|
||||
comment_chars,
|
||||
currency
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
description,
|
||||
min,
|
||||
max,
|
||||
webhook_url,
|
||||
success_text,
|
||||
success_url,
|
||||
comment_chars,
|
||||
currency,
|
||||
),
|
||||
)
|
||||
link_id = db.cursor.lastrowid
|
||||
return get_pay_link(link_id)
|
||||
|
||||
|
||||
def get_pay_link(link_id: int) -> Optional[PayLink]:
|
||||
with open_ext_db("lnurlp") as db:
|
||||
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
||||
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
with open_ext_db("lnurlp") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM pay_links WHERE wallet IN ({q})
|
||||
ORDER BY Id
|
||||
""",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
|
||||
return [PayLink.from_row(row) for row in rows]
|
||||
|
||||
|
||||
def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
|
||||
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
||||
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
|
||||
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
|
||||
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
|
||||
|
||||
return PayLink.from_row(row) if row else None
|
||||
|
||||
|
||||
def delete_pay_link(link_id: int) -> None:
|
||||
with open_ext_db("lnurlp") as db:
|
||||
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
|
||||
|
||||
|
||||
def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
payment.extra["wh_status"] = status
|
||||
g.db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(json.dumps(payment.extra), payment.payment_hash),
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
import trio # type: ignore
|
||||
import httpx
|
||||
|
||||
|
||||
async def get_fiat_rate(currency: str):
|
||||
assert currency == "USD", "Only USD is supported as fiat currency."
|
||||
return await get_usd_rate()
|
||||
|
||||
|
||||
async def get_usd_rate():
|
||||
"""
|
||||
Returns an average satoshi price from multiple sources.
|
||||
"""
|
||||
|
||||
satoshi_prices = [None, None, None]
|
||||
|
||||
async def fetch_price(index, url, getter):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
satoshi_price = int(100_000_000 / float(getter(r.json())))
|
||||
satoshi_prices[index] = satoshi_price
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with trio.open_nursery() as nursery:
|
||||
nursery.start_soon(
|
||||
fetch_price,
|
||||
0,
|
||||
"https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD",
|
||||
lambda d: d["result"]["XXBTCZUSD"]["c"][0],
|
||||
)
|
||||
nursery.start_soon(
|
||||
fetch_price,
|
||||
1,
|
||||
"https://www.bitstamp.net/api/v2/ticker/btcusd",
|
||||
lambda d: d["last"],
|
||||
)
|
||||
nursery.start_soon(
|
||||
fetch_price,
|
||||
2,
|
||||
"https://api.coincap.io/v2/rates/bitcoin",
|
||||
lambda d: d["data"]["rateUsd"],
|
||||
)
|
||||
|
||||
satoshi_prices = [x for x in satoshi_prices if x]
|
||||
return sum(satoshi_prices) / len(satoshi_prices)
|
||||
@@ -0,0 +1,88 @@
|
||||
import hashlib
|
||||
import math
|
||||
from http import HTTPStatus
|
||||
from quart import jsonify, url_for, request
|
||||
from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
|
||||
from . import lnurlp_ext
|
||||
from .crud import increment_pay_link
|
||||
from .helpers import get_fiat_rate
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
|
||||
async def api_lnurl_response(link_id):
|
||||
link = increment_pay_link(link_id, served_meta=1)
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
|
||||
rate = await get_fiat_rate(link.currency) if link.currency else 1
|
||||
resp = LnurlPayResponse(
|
||||
callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
|
||||
min_sendable=math.ceil(link.min * rate) * 1000,
|
||||
max_sendable=round(link.max * rate) * 1000,
|
||||
metadata=link.lnurlpay_metadata,
|
||||
)
|
||||
params = resp.dict()
|
||||
|
||||
if link.comment_chars > 0:
|
||||
params["commentAllowed"] = link.comment_chars
|
||||
|
||||
return jsonify(params), HTTPStatus.OK
|
||||
|
||||
|
||||
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
|
||||
async def api_lnurl_callback(link_id):
|
||||
link = increment_pay_link(link_id, served_pr=1)
|
||||
if not link:
|
||||
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
|
||||
|
||||
min, max = link.min, link.max
|
||||
rate = await get_fiat_rate(link.currency) if link.currency else 1
|
||||
if link.currency:
|
||||
# allow some fluctuation (as the fiat price may have changed between the calls)
|
||||
min = rate * 995 * link.min
|
||||
max = rate * 1010 * link.max
|
||||
else:
|
||||
min = link.min * 1000
|
||||
max = link.max * 1000
|
||||
|
||||
amount_received = int(request.args.get("amount"))
|
||||
if amount_received < min:
|
||||
return (
|
||||
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is smaller than minimum {min}.").dict()),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
elif amount_received > max:
|
||||
return (
|
||||
jsonify(LnurlErrorResponse(reason=f"Amount {amount_received} is greater than maximum {max}.").dict()),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
comment = request.args.get("comment")
|
||||
if len(comment or "") > link.comment_chars:
|
||||
return (
|
||||
jsonify(
|
||||
LnurlErrorResponse(
|
||||
reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
|
||||
).dict()
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
payment_hash, payment_request = create_invoice(
|
||||
wallet_id=link.wallet,
|
||||
amount=int(amount_received / 1000),
|
||||
memo=link.description,
|
||||
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
|
||||
extra={"tag": "lnurlp", "link": link.id, "comment": comment},
|
||||
)
|
||||
|
||||
resp = LnurlPayActionResponse(
|
||||
pr=payment_request,
|
||||
success_action=link.success_action(payment_hash),
|
||||
routes=[],
|
||||
)
|
||||
|
||||
return jsonify(resp.dict()), HTTPStatus.OK
|
||||
@@ -0,0 +1,48 @@
|
||||
def m001_initial(db):
|
||||
"""
|
||||
Initial pay table.
|
||||
"""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pay_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wallet TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
served_meta INTEGER NOT NULL,
|
||||
served_pr INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def m002_webhooks_and_success_actions(db):
|
||||
"""
|
||||
Webhooks and success actions.
|
||||
"""
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;")
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;")
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE invoices (
|
||||
pay_link INTEGER NOT NULL REFERENCES pay_links (id),
|
||||
payment_hash TEXT NOT NULL,
|
||||
webhook_sent INT, -- null means not sent, otherwise store status
|
||||
expiry INT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def m003_min_max_comment_fiat(db):
|
||||
"""
|
||||
Support for min/max amounts, comments and fiat prices that get
|
||||
converted automatically to satoshis based on some API.
|
||||
"""
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN currency TEXT;") # null = satoshis
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;")
|
||||
db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;")
|
||||
db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;")
|
||||
db.execute("UPDATE pay_links SET max = min;")
|
||||
db.execute("DROP TABLE invoices")
|
||||
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
|
||||
from quart import url_for
|
||||
from typing import NamedTuple, Optional, Dict
|
||||
from sqlite3 import Row
|
||||
from lnurl import Lnurl, encode as lnurl_encode
|
||||
from lnurl.types import LnurlPayMetadata
|
||||
from lnurl.models import LnurlPaySuccessAction, MessageAction, UrlAction
|
||||
|
||||
|
||||
class PayLink(NamedTuple):
|
||||
id: int
|
||||
wallet: str
|
||||
description: str
|
||||
min: int
|
||||
served_meta: int
|
||||
served_pr: int
|
||||
webhook_url: str
|
||||
success_text: str
|
||||
success_url: str
|
||||
currency: str
|
||||
comment_chars: int
|
||||
max: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "PayLink":
|
||||
data = dict(row)
|
||||
return cls(**data)
|
||||
|
||||
@property
|
||||
def lnurl(self) -> Lnurl:
|
||||
url = url_for("lnurlp.api_lnurl_response", link_id=self.id, _external=True)
|
||||
return lnurl_encode(url)
|
||||
|
||||
@property
|
||||
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
|
||||
|
||||
def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
|
||||
if self.success_url:
|
||||
url: ParseResult = urlparse(self.success_url)
|
||||
qs: Dict = parse_qs(url.query)
|
||||
qs["payment_hash"] = payment_hash
|
||||
url = url._replace(query=urlencode(qs, doseq=True))
|
||||
raw: str = urlunparse(url)
|
||||
return UrlAction(url=raw, description=self.success_text)
|
||||
elif self.success_text:
|
||||
return MessageAction(message=self.success_text)
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,207 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var mapPayLink = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
||||
obj.pay_url = [locationPath, obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
fiatRates: {},
|
||||
checker: null,
|
||||
payLinks: [],
|
||||
payLinksTable: {
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
fixedAmount: true,
|
||||
data: {}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getPayLinks() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/lnurlp/api/v1/links?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = response.data.map(mapPayLink)
|
||||
})
|
||||
.catch(err => {
|
||||
clearInterval(this.checker)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
closeFormDialog() {},
|
||||
openQrCodeDialog(linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
||||
this.qrCodeDialog.data = {
|
||||
id: link.id,
|
||||
amount:
|
||||
(link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
|
||||
' ' +
|
||||
(link.currency || 'sat'),
|
||||
currency: link.currency,
|
||||
comments: link.comment_chars
|
||||
? `${link.comment_chars} characters`
|
||||
: 'no',
|
||||
webhook: link.webhook_url || 'nowhere',
|
||||
success:
|
||||
link.success_text || link.success_url
|
||||
? 'Display message "' +
|
||||
link.success_text +
|
||||
'"' +
|
||||
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
|
||||
: 'do nothing',
|
||||
lnurl: link.lnurl,
|
||||
pay_url: link.pay_url,
|
||||
print_url: link.print_url
|
||||
}
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
openUpdateDialog(linkId) {
|
||||
const link = _.findWhere(this.payLinks, {id: linkId})
|
||||
if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
||||
this.formDialog.data = _.clone(link._data)
|
||||
this.formDialog.show = true
|
||||
this.formDialog.fixedAmount =
|
||||
this.formDialog.data.min === this.formDialog.data.max
|
||||
},
|
||||
sendFormData() {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
|
||||
if (this.formDialog.fixedAmount) data.max = data.min
|
||||
if (data.currency === 'satoshis') data.currency = null
|
||||
if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
|
||||
|
||||
if (data.id) {
|
||||
this.updatePayLink(wallet, data)
|
||||
} else {
|
||||
this.createPayLink(wallet, data)
|
||||
}
|
||||
},
|
||||
updatePayLink(wallet, data) {
|
||||
let values = _.omit(
|
||||
_.pick(
|
||||
data,
|
||||
'description',
|
||||
'min',
|
||||
'max',
|
||||
'webhook_url',
|
||||
'success_text',
|
||||
'success_url',
|
||||
'comment_chars',
|
||||
'currency'
|
||||
),
|
||||
(value, key) =>
|
||||
(key === 'webhook_url' ||
|
||||
key === 'success_text' ||
|
||||
key === 'success_url') &&
|
||||
(value === null || value === '')
|
||||
)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/lnurlp/api/v1/links/' + data.id,
|
||||
wallet.adminkey,
|
||||
values
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
||||
this.payLinks.push(mapPayLink(response.data))
|
||||
this.formDialog.show = false
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
createPayLink(wallet, data) {
|
||||
LNbits.api
|
||||
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
|
||||
.then(response => {
|
||||
this.payLinks.push(mapPayLink(response.data))
|
||||
this.formDialog.show = false
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deletePayLink: linkId => {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/lnurlp/api/v1/links/' + linkId,
|
||||
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
updateFiatRate(currency) {
|
||||
LNbits.api
|
||||
.request('GET', '/lnurlp/api/v1/rate/' + currency, null)
|
||||
.then(response => {
|
||||
let rates = _.clone(this.fiatRates)
|
||||
rates[currency] = response.data.rate
|
||||
this.fiatRates = rates
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getPayLinks = this.getPayLinks
|
||||
getPayLinks()
|
||||
this.checker = setInterval(() => {
|
||||
getPayLinks()
|
||||
}, 20000)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import trio # type: ignore
|
||||
import httpx
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.tasks import run_on_pseudo_request, register_invoice_listener
|
||||
|
||||
from .crud import mark_webhook_sent, get_pay_link
|
||||
|
||||
|
||||
async def register_listeners():
|
||||
invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
|
||||
register_invoice_listener(invoice_paid_chan_send)
|
||||
await wait_for_paid_invoices(invoice_paid_chan_recv)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
|
||||
async for payment in invoice_paid_chan:
|
||||
await run_on_pseudo_request(on_invoice_paid, payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if "lnurlp" != payment.extra.get("tag"):
|
||||
# not an lnurlp invoice
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
# this webhook has already been sent
|
||||
return
|
||||
|
||||
pay_link = get_pay_link(payment.extra.get("link", -1))
|
||||
if pay_link and pay_link.webhook_url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
pay_link.webhook_url,
|
||||
json={
|
||||
"payment_hash": payment.payment_hash,
|
||||
"payment_request": payment.bolt11,
|
||||
"amount": payment.amount,
|
||||
"comment": payment.extra.get("comment"),
|
||||
"lnurlp": pay_link.id,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
mark_webhook_sent(payment, -1)
|
||||
@@ -0,0 +1,129 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-expansion-item group="api" dense expand-separator label="List pay links">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span> /api/v1/links</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<pay_link_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}pay/api/v1/links -H "X-Api-Key: {{
|
||||
g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get a pay link">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span> /api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>{"lnurl": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}pay/api/v1/links/<pay_id> -H
|
||||
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create a pay link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-green">POST</span> /api/v1/links</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"description": <string> "amount": <integer>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>{"lnurl": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}pay/api/v1/links -d
|
||||
'{"description": <string>, "amount": <integer>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Update a pay link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">PUT</span>
|
||||
/api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"description": <string>, "amount": <integer>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"lnurl": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.url_root }}pay/api/v1/links/<pay_id> -d
|
||||
'{"description": <string>, "amount": <integer>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a pay link"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.url_root }}pay/api/v1/links/<pay_id>
|
||||
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user