Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
510ccbc007 | ||
|
|
08d124c62a | ||
|
|
e112258c39 | ||
|
|
b2efd71d3c | ||
|
|
61c8d70fe7 | ||
|
|
3215b5d2bb | ||
|
|
574358a118 | ||
|
|
85011d23c3 | ||
|
|
9cc7052920 | ||
|
|
6164b9d27e | ||
|
|
a787d60d93 | ||
|
|
aabe3364f4 | ||
|
|
d4d069fc77 | ||
|
|
42bd5ea989 | ||
|
|
3333f1f3f3 | ||
|
|
e6ea77e682 | ||
|
|
8e62d9287d | ||
|
|
7e4a42e7ff | ||
|
|
f27c2ebc21 | ||
|
|
e4fb18a53a | ||
|
|
fa383ced1c | ||
|
|
ddb277ff92 | ||
|
|
1e9151cedc | ||
|
|
20ef9f92a1 | ||
|
|
c86bd74506 | ||
|
|
ad545e7fe1 | ||
|
|
66cde0154b | ||
|
|
422298948c | ||
|
|
09f89d07dd | ||
|
|
f42dab5e30 | ||
|
|
86744ebced | ||
|
|
514329045f | ||
|
|
7cd3487bc9 | ||
|
|
1a460a2ce5 | ||
|
|
b5c4fe905f | ||
|
|
d511e92a04 | ||
|
|
d69c5aa2b1 | ||
|
|
b5ea8e2546 | ||
|
|
8df4dd702d | ||
|
|
1bc59974a8 | ||
|
|
0bc66ea150 | ||
|
|
adc3e62573 | ||
|
|
5142f1e29f | ||
|
|
d13ca2afdb | ||
|
|
a653a5327b | ||
|
|
d9b9d1e9b2 | ||
|
|
4c8ce1bfed | ||
|
|
1630a28da0 | ||
|
|
80e7c2c50f | ||
|
|
c7717a611a | ||
|
|
773103f893 | ||
|
|
63ae553565 | ||
|
|
cda0819f64 | ||
|
|
732d06c1e5 | ||
|
|
f571f5f840 | ||
|
|
d0039ca71c | ||
|
|
33a90a8de3 | ||
|
|
1f4218d5c2 | ||
|
|
4fee785229 | ||
|
|
97e519d569 | ||
|
|
3bfca5b7b7 | ||
|
|
13f440d517 | ||
|
|
2d4e9202f1 | ||
|
|
d913ab1306 | ||
|
|
0ed281e0fb | ||
|
|
c03c463171 | ||
|
|
32a3dbb772 | ||
|
|
02a8b86168 | ||
|
|
042052e4e5 | ||
|
|
8cefb2f8fe | ||
|
|
bd46c0be91 | ||
|
|
0ba26975d4 | ||
|
|
4500078773 | ||
|
|
c6437f3fbc | ||
|
|
dbe028e1eb | ||
|
|
ec193ca697 | ||
|
|
ea51af90d0 | ||
|
|
378e408dca | ||
|
|
e251291076 | ||
|
|
88eb8e0e78 | ||
|
|
9c126696fc | ||
|
|
9dd040186d | ||
|
|
45148d285e | ||
|
|
e96c92e6df | ||
|
|
14b6e21a02 | ||
|
|
2a25932a85 | ||
|
|
d811d69dac | ||
|
|
b6cb305e5c | ||
|
|
babaa4459e | ||
|
|
f45aadaafe | ||
|
|
f6010bc139 | ||
|
|
33b48341f1 | ||
|
|
dc4a786dda | ||
|
|
10414be0d2 | ||
|
|
e1abea77f5 | ||
|
|
8a7652c06f | ||
|
|
be0f853346 | ||
|
|
afa7fce1c4 | ||
|
|
94b3416300 | ||
|
|
c082c953f3 | ||
|
|
128888be92 | ||
|
|
c5c5bdb287 | ||
|
|
b91271315a | ||
|
|
bd4a668d23 | ||
|
|
399a07b98a | ||
|
|
493ab5464e | ||
|
|
6c4b5ea406 | ||
|
|
c0d7371137 | ||
|
|
e96ec08f44 | ||
|
|
2eb44674d0 | ||
|
|
307a919d17 | ||
|
|
3c398a8276 | ||
|
|
1c922a5ddc | ||
|
|
4623220316 | ||
|
|
503c981bc9 | ||
|
|
31b1c0d1d5 | ||
|
|
ae59c74c24 | ||
|
|
52956a62a2 | ||
|
|
ccb9d107d5 | ||
|
|
edc533e902 | ||
|
|
9db327d5db | ||
|
|
16fff9899f | ||
|
|
67e8c567a3 | ||
|
|
409f62dee7 | ||
|
|
d4e718d29b | ||
|
|
d84915cb00 | ||
|
|
03706dcbad | ||
|
|
d3fc52cd49 | ||
|
|
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 | ||
|
|
6cf488ea4f | ||
|
|
e01de0eae5 | ||
|
|
5650731bce | ||
|
|
53d65c0258 | ||
|
|
c4db2d5c28 | ||
|
|
b97595b06d | ||
|
|
dd2a282158 | ||
|
|
3c7e4a3eaf | ||
|
|
89c31139f8 | ||
|
|
2d580a0d0e | ||
|
|
0497c603c0 | ||
|
|
5831fe2ab6 | ||
|
|
dfdb890751 | ||
|
|
3735fe7aa1 | ||
|
|
97a13526d9 | ||
|
|
be04623b9d | ||
|
|
c56784340e | ||
|
|
0603e525ff | ||
|
|
410e96771e | ||
|
|
784a2dd876 | ||
|
|
bc930ab606 | ||
|
|
f518e350a6 | ||
|
|
8055e6a73c | ||
|
|
6b2061f107 | ||
|
|
ce2f764432 | ||
|
|
efe4b91877 | ||
|
|
55a9dc749a | ||
|
|
bc984ab0db | ||
|
|
1e36a8a393 | ||
|
|
c17ac32c86 | ||
|
|
c73fbe1b27 | ||
|
|
2ddc23de04 | ||
|
|
294beb2fca | ||
|
|
6c286cd265 | ||
|
|
68e41789ce | ||
|
|
e746dcd787 | ||
|
|
24dc6e5415 | ||
|
|
bc27293315 | ||
|
|
61f736878c | ||
|
|
f51d44e99b | ||
|
|
95b0ba536b | ||
|
|
5e81c64831 | ||
|
|
d56c2627f4 | ||
|
|
c3281399d2 | ||
|
|
8ea72c9934 | ||
|
|
aed2414f3a | ||
|
|
76f17d8594 | ||
|
|
85e617aae9 | ||
|
|
68d94a43d0 | ||
|
|
29dc33dd71 | ||
|
|
a54bff6640 | ||
|
|
d2874a90e9 | ||
|
|
c099885bbb | ||
|
|
b296c38795 | ||
|
|
9af4161679 | ||
|
|
76510b7892 | ||
|
|
ea3b858695 | ||
|
|
41277e6931 | ||
|
|
8add56a24c | ||
|
|
e768e4b075 | ||
|
|
a40823de43 | ||
|
|
171e639109 | ||
|
|
6f4cac0c41 | ||
|
|
97dff38af4 | ||
|
|
13f01dfbe6 | ||
|
|
14d61eaf56 | ||
|
|
63b9741c14 | ||
|
|
22451ca806 | ||
|
|
e73a508011 | ||
|
|
2fac47c05a | ||
|
|
b8dd151043 | ||
|
|
42d307adf9 | ||
|
|
c3ce18c8d9 | ||
|
|
d914c3f701 | ||
|
|
52aff1128e | ||
|
|
9eb253fe55 | ||
|
|
35fb524e1e | ||
|
|
01c8e8a23f | ||
|
|
b37af0c600 | ||
|
|
a61d346669 | ||
|
|
8530413537 | ||
|
|
3a6034e8f6 | ||
|
|
d9fe80d41b | ||
|
|
4730500ed7 | ||
|
|
083f7a0a8d | ||
|
|
3a1167c892 | ||
|
|
236bcac263 | ||
|
|
2c5e539795 | ||
|
|
5553ed82f0 | ||
|
|
9fadb68fed | ||
|
|
d496869572 | ||
|
|
9f33ed49e5 | ||
|
|
ea480866ea | ||
|
|
690b33e096 | ||
|
|
d72678972d | ||
|
|
8dfdd15adb | ||
|
|
8442f277c4 | ||
|
|
8f76da66e8 | ||
|
|
f7ca28db34 | ||
|
|
c951577962 | ||
|
|
0847a1c933 | ||
|
|
f731bd766e | ||
|
|
59819e2875 | ||
|
|
e7184346ab | ||
|
|
178fe65fb2 | ||
|
|
1c8beba80b | ||
|
|
1516a14a0d |
+36
-25
@@ -1,45 +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=""
|
||||
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, 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,2 +0,0 @@
|
||||
lnbits/static/vendor/** linguist-vendored
|
||||
lnbits/extensions/**/static/vendor/** linguist-vendored
|
||||
@@ -0,0 +1 @@
|
||||
custom: https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
|
||||
@@ -0,0 +1,28 @@
|
||||
name: codeql
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, ]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: '0 12 * * 5'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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,13 +1,12 @@
|
||||
name: mypy type checker
|
||||
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'
|
||||
path: lnbits
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Docker build on push
|
||||
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
name: Build and push lnbits image
|
||||
steps:
|
||||
- name: Login to Docker Hub
|
||||
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
id: qemu
|
||||
|
||||
- name: Setup Docker buildx action
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: buildx
|
||||
|
||||
- name: Show available Docker buildx platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
id: cache
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: Run Docker buildx against commit hash
|
||||
run: |
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:${GITHUB_SHA:0:7} \
|
||||
--output "type=registry" ./
|
||||
|
||||
- name: Run Docker buildx against latest
|
||||
run: |
|
||||
docker buildx build \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
--tag ${{ secrets.DOCKER_USERNAME }}/lnbits:latest \
|
||||
--output "type=registry" ./
|
||||
@@ -0,0 +1,36 @@
|
||||
name: tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.7, 3.8]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
- name: Test with pytest
|
||||
env:
|
||||
LNBITS_BACKEND_WALLET_CLASS: LNPayWallet
|
||||
LNBITS_FORCE_HTTPS: 0
|
||||
LNPAY_API_ENDPOINT: https://lnpay.co/v1/
|
||||
LNPAY_API_KEY: sak_gG5pSFZhFgOLHm26a8hcWvXKt98yd
|
||||
LNPAY_ADMIN_KEY: waka_HqWfOoNE0TPqmQHSYErbF4n9
|
||||
LNPAY_INVOICE_KEY: waki_ZqFEbhrTyopuPlOZButZUw
|
||||
LNPAY_READ_KEY: wakr_6IyTaNrvSeu3jbojSWt4ou6h
|
||||
run: |
|
||||
pip install pytest pytest-cov
|
||||
pytest --cov=lnbits --cov-report=xml
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
@@ -21,9 +21,13 @@ test-reports
|
||||
*.pyc
|
||||
*.env
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
|
||||
*.sqlite3
|
||||
.pyre*
|
||||
|
||||
__bundle__
|
||||
|
||||
node_modules
|
||||
lnbits/static/bundle.*
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"semi": false,
|
||||
"arrowParens": "avoid",
|
||||
"insertPragma": false,
|
||||
"printWidth": 80,
|
||||
"proseWrap": "preserve",
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"bracketSpacing": false
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
For developers
|
||||
==============
|
||||
|
||||
LNbits uses [Flask](http://flask.pocoo.org/).
|
||||
Feel free to contribute to the project.
|
||||
|
||||
Application dependencies
|
||||
------------------------
|
||||
The application uses [Pipenv][pipenv] to manage Python packages.
|
||||
While in development, you will need to install all dependencies:
|
||||
|
||||
$ pipenv shell
|
||||
$ pipenv install --dev
|
||||
|
||||
Running the server
|
||||
------------------
|
||||
|
||||
$ flask run
|
||||
|
||||
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
|
||||
|
||||
Style guide
|
||||
-----------
|
||||
Tab size is 4 spaces. Maximum line length is 120. You should run `black` before commiting any change.
|
||||
|
||||
$ black lnbits
|
||||
|
||||
[pipenv]: https://docs.pipenv.org/#install-pipenv-today
|
||||
+44
-4
@@ -1,8 +1,48 @@
|
||||
FROM python:3.7
|
||||
# Build image
|
||||
FROM python:3.7-slim as builder
|
||||
|
||||
# Setup virtualenv
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
RUN python -m venv $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
# Install build deps
|
||||
RUN apt-get update
|
||||
RUN apt-get install -y --no-install-recommends build-essential
|
||||
|
||||
# Install runtime deps
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install -r /tmp/requirements.txt
|
||||
|
||||
# Install c-lightning specific deps
|
||||
RUN pip install pylightning
|
||||
|
||||
# Install LND specific deps
|
||||
RUN pip install lndgrpc purerpc
|
||||
|
||||
# Production image
|
||||
FROM python:3.7-slim as lnbits
|
||||
|
||||
# Run as non-root
|
||||
USER 1000:1000
|
||||
|
||||
# Copy over virtualenv
|
||||
ENV VIRTUAL_ENV="/opt/venv"
|
||||
COPY --from=builder --chown=1000:1000 $VIRTUAL_ENV $VIRTUAL_ENV
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
# Setup Quart
|
||||
ENV QUART_APP="lnbits.app:create_app()"
|
||||
ENV QUART_ENV="development"
|
||||
ENV QUART_DEBUG="true"
|
||||
|
||||
# App
|
||||
ENV LNBITS_BIND="0.0.0.0:5000"
|
||||
|
||||
# Copy in app source
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -q -r requirements.txt
|
||||
COPY . /app
|
||||
COPY --chown=1000:1000 lnbits /app/lnbits
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD quart assets && quart migrate && hypercorn -k trio --bind $LNBITS_BIND 'lnbits.app:create_app()'
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
./venv/bin/mypy lnbits/core
|
||||
./venv/bin/mypy lnbits/extensions/*
|
||||
|
||||
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
|
||||
|
||||
Pipfile.lock: Pipfile
|
||||
./venv/bin/pipenv lock
|
||||
|
||||
requirements.txt: Pipfile.lock
|
||||
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
|
||||
@@ -9,23 +9,27 @@ python_version = "3.7"
|
||||
[packages]
|
||||
bitstring = "*"
|
||||
cerberus = "*"
|
||||
lnd-grpc = "*"
|
||||
ecdsa = "*"
|
||||
environs = "*"
|
||||
lnurl = "*"
|
||||
flask = "*"
|
||||
flask-assets = "*"
|
||||
flask-compress = "*"
|
||||
flask-talisman = "*"
|
||||
gevent = "*"
|
||||
gunicorn = "*"
|
||||
pylightning = "*"
|
||||
pyscss = "*"
|
||||
requests = "*"
|
||||
shortuuid = "*"
|
||||
quart = "*"
|
||||
quart-cors = "*"
|
||||
quart-compress = "*"
|
||||
secure = "*"
|
||||
typing-extensions = "*"
|
||||
httpx = "*"
|
||||
quart-trio = "*"
|
||||
trio = "==0.16.0"
|
||||
hypercorn = {extras = ["trio"], version = "*"}
|
||||
sqlalchemy-aio = "*"
|
||||
pyqrcode = "*"
|
||||
pypng = "*"
|
||||
|
||||
[dev-packages]
|
||||
black = "==19.10b0"
|
||||
flake8 = "*"
|
||||
flake8-mypy = "*"
|
||||
black = "==20.8b1"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
pytest-sugar = "*"
|
||||
mypy = "latest"
|
||||
pytest-trio = "*"
|
||||
|
||||
Generated
+659
-461
File diff suppressed because it is too large
Load Diff
@@ -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,75 +1,84 @@
|
||||
LNbits
|
||||
======
|
||||
|
||||
[![github-tests-badge]][github-tests]
|
||||
[![github-mypy-badge]][github-mypy]
|
||||
[![codecov-badge]][codecov]
|
||||
[![license-badge]](LICENSE)
|
||||
[![docs-badge]][docs]
|
||||
|
||||
|
||||

|
||||
# LNbits v0.1 BETA, free and open-source lightning-network wallet/accounts system
|
||||
https://lnbits.com, or run your own LNbits server!
|
||||
|
||||
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
|
||||
# LNbits v0.1 BETA, free and open-source lightning-network wallet/accounts system
|
||||
|
||||
Use [lnbits.com](https://lnbits.com), or run your own LNbits server!
|
||||
|
||||
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
|
||||
|
||||
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Part of a development stack via LNbits API
|
||||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
* 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.
|
||||
LNbits 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.
|
||||
|
||||
## 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
|
||||
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv + more to come..
|
||||
|
||||
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv + more to come..
|
||||
|
||||

|
||||
|
||||
Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
|
||||
Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
|
||||
|
||||
(LNbits M5StackSats available here https://github.com/arcbtc/M5StackSats)
|
||||
(LNbits M5StackSats available here https://github.com/arcbtc/M5StackSats)
|
||||
|
||||

|
||||
|
||||
## LNbits as an LNURL-withdraw fallback
|
||||
LNURL has a fallback scheme, so if scanned by a regular QR code reader it can default to a URL. LNbits exploits this to generate an instant wallet using the LNURL-withdraw.
|
||||
|
||||
LNURL has a fallback scheme, so if scanned by a regular QR code reader it can default to a URL. LNbits exploits this to generate an instant wallet using the [LNURL-withdraw](https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-withdraw.md).
|
||||
|
||||

|
||||
https://github.com/btcontract/lnurl-rfc/blob/master/spec.md
|
||||
|
||||
Adding **/lnurlwallet?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
|
||||
Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
|
||||
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will stilll be able to access the funds.
|
||||
|
||||

|
||||
|
||||
## LNbits as an insta-wallet
|
||||
|
||||
Wallets can be easily generated and given out to people at events (one click multi-wallet generation to be added soon).
|
||||
"Go to this website", has a lot less friction than "Download this app".
|
||||
|
||||

|
||||
|
||||
# Running LNbits locally
|
||||
Download this repo
|
||||
## Tip us
|
||||
|
||||
LNbits uses [Flask](http://flask.pocoo.org/).
|
||||
Feel free to contribute to the project.
|
||||
If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
|
||||
|
||||
Application dependencies
|
||||
------------------------
|
||||
The application uses [Pipenv][pipenv] to manage Python packages.
|
||||
While in development, you will need to install all dependencies:
|
||||
|
||||
$ pipenv shell
|
||||
$ pipenv install --dev
|
||||
|
||||
You will need to set the variables in .env.example, and rename the file to .env
|
||||
|
||||

|
||||
|
||||
Running the server
|
||||
------------------
|
||||
|
||||
$ flask migrate
|
||||
$ flask run
|
||||
|
||||
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://docs.pipenv.org/#install-pipenv-today
|
||||
|
||||
# Tip me
|
||||
If you like this project and might even use or extend it, why not send some tip love!
|
||||
https://paywall.link/to/f4e4e
|
||||
[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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
_site
|
||||
.sass-cache
|
||||
.jekyll-cache
|
||||
.jekyll-metadata
|
||||
vendor
|
||||
@@ -0,0 +1 @@
|
||||
lnbits.org
|
||||
@@ -0,0 +1,11 @@
|
||||
title: "LNbits docs"
|
||||
|
||||
remote_theme: pmarsceill/just-the-docs
|
||||
logo: "/logos/lnbits-full.png"
|
||||
search_enabled: true
|
||||
url: https://lnbits.org
|
||||
aux_links:
|
||||
"LNbits on GitHub":
|
||||
- "//github.com/lnbits/lnbits"
|
||||
"lnbits.com":
|
||||
- "//lnbits.com"
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
layout: default
|
||||
parent: For developers
|
||||
title: API reference
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
|
||||
API reference
|
||||
=============
|
||||
|
||||
Coming soon...
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
layout: default
|
||||
title: For developers
|
||||
nav_order: 4
|
||||
has_children: true
|
||||
---
|
||||
|
||||
|
||||
For developers
|
||||
==============
|
||||
|
||||
Thanks for contributing :)
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
layout: default
|
||||
parent: For developers
|
||||
title: Making extensions
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
|
||||
Making extensions
|
||||
=================
|
||||
|
||||
Start off by copying the example extension in `lnbits/extensions/example` into your own:
|
||||
```sh
|
||||
cp lnbits/extensions/example lnbits/extensions/mysuperplugin -r # Let's not use dashes or anything; it doesn't like those.
|
||||
cd lnbits/extensions/mysuperplugin
|
||||
find . -type f -print0 | xargs -0 sed -i 's/example/mysuperplugin/g' # Change all occurrences of 'example' to your plugin name 'mysuperplugin'.
|
||||
```
|
||||
|
||||
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 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
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
layout: default
|
||||
parent: For developers
|
||||
title: Installation
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
Download the latest stable release https://github.com/lnbits/lnbits/releases
|
||||
|
||||
|
||||
Application dependencies
|
||||
------------------------
|
||||
|
||||
The application uses [Pipenv][pipenv] to manage Python packages.
|
||||
While in development, you will need to install all dependencies:
|
||||
|
||||
```sh
|
||||
$ pipenv shell
|
||||
$ pipenv install --dev
|
||||
```
|
||||
|
||||
If any of the modules fails to install, try checking and upgrading your setupTool module.
|
||||
`pip install -U setuptools`
|
||||
|
||||
If you wish to use a version of Python higher than 3.7:
|
||||
|
||||
```sh
|
||||
$ pipenv --python 3.8 install --dev
|
||||
```
|
||||
|
||||
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 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.
|
||||
|
||||
|
||||
Running the server
|
||||
------------------
|
||||
|
||||
LNbits uses [Quart][quart] as an application server.
|
||||
|
||||
```sh
|
||||
$ pipenv run python -m lnbits
|
||||
```
|
||||
|
||||
Frontend
|
||||
--------
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
layout: default
|
||||
title: Backend wallets
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
|
||||
Backend wallets
|
||||
===============
|
||||
|
||||
LNbits can run on top of many lightning-network funding sources. Currently there is support for
|
||||
CLightning, LND, LNbits, LNPay, lntxbot and OpenNode, with more being added regularily.
|
||||
|
||||
A backend wallet can be configured using the following LNbits environment variables:
|
||||
|
||||
|
||||
### CLightning
|
||||
|
||||
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 `lndgrpc` and `purerpc` Python packages.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `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_REST_CERT`: /file/path/tls.cert
|
||||
- `LND_GRPC_MACAROON`: /file/path/admin.macaroon
|
||||
|
||||
|
||||
### LNbits
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
||||
- `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`: https://lnpay.co/v1/
|
||||
- `LNPAY_API_KEY`: sak_apiKey
|
||||
- `LNPAY_WALLET_KEY`: waka_apiKey
|
||||
|
||||
|
||||
### lntxbot
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
|
||||
- `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`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
layout: default
|
||||
title: User’s Guide
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
|
||||
LNbits, free and open-source lightning-network wallet/accounts system
|
||||
=====================================================================
|
||||
|
||||
LNbits is a very simple Python application that sits on top of any funding source, and can be used as:
|
||||
|
||||
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
|
||||
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
|
||||
* Part of a development stack via LNbits API
|
||||
* Fallback wallet for the LNURL scheme
|
||||
* Instant wallet for LN demonstrations
|
||||
|
||||
|
||||
LNbits as an account system
|
||||
---------------------------
|
||||
|
||||
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending,
|
||||
export to csv + more to come...
|
||||
@@ -1,94 +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_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)
|
||||
Talisman(
|
||||
app,
|
||||
force_https=FORCE_HTTPS,
|
||||
content_security_policy={
|
||||
"default-src": [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
"blob:",
|
||||
"api.opennode.co",
|
||||
"fonts.googleapis.com",
|
||||
"fonts.gstatic.com",
|
||||
"github.com",
|
||||
"avatars2.githubusercontent.com",
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# 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,33 @@
|
||||
import trio # type: ignore
|
||||
|
||||
from .commands import migrate_databases, transpile_scss, bundle_vendored
|
||||
|
||||
trio.run(migrate_databases)
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
from .app import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
from .settings import (
|
||||
LNBITS_SITE_TITLE,
|
||||
SERVICE_FEE,
|
||||
DEBUG,
|
||||
LNBITS_DATA_FOLDER,
|
||||
WALLET,
|
||||
LNBITS_COMMIT,
|
||||
)
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import sys
|
||||
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 .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,
|
||||
check_pending_payments,
|
||||
invoice_listener,
|
||||
internal_invoice_listener,
|
||||
webhook_handler,
|
||||
grab_app_for_later,
|
||||
)
|
||||
from .settings import WALLET
|
||||
|
||||
secure_headers = SecureHeaders(hsts=False, xfo=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 = await WALLET.status()
|
||||
if error_message:
|
||||
warnings.warn(
|
||||
f" × The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
|
||||
RuntimeWarning,
|
||||
)
|
||||
|
||||
sys.exit(4)
|
||||
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")
|
||||
|
||||
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.nursery = app.nursery
|
||||
|
||||
@app.after_request
|
||||
async def set_secure_headers(response):
|
||||
secure_headers.quart(response)
|
||||
return response
|
||||
|
||||
|
||||
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(check_pending_payments)
|
||||
app.nursery.start_soon(invoice_listener, app.nursery)
|
||||
app.nursery.start_soon(internal_invoice_listener, app.nursery)
|
||||
|
||||
@app.after_serving
|
||||
async def stop_listeners():
|
||||
pass
|
||||
+97
-32
@@ -1,66 +1,123 @@
|
||||
# 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 +140,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 +159,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,93 @@
|
||||
import trio # type: ignore
|
||||
import warnings
|
||||
import click
|
||||
import importlib
|
||||
import re
|
||||
import os
|
||||
from sqlalchemy.exc import OperationalError # type: ignore
|
||||
|
||||
from .core import db as core_db, migrations as core_migrations
|
||||
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():
|
||||
trio.run(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)
|
||||
|
||||
|
||||
async def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
async with core_db.connect() as conn:
|
||||
try:
|
||||
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
|
||||
except OperationalError:
|
||||
# migration 3 wasn't ran
|
||||
await core_migrations.m000_create_migrations_table(conn)
|
||||
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
|
||||
|
||||
current_versions = {row["db"]: row["version"] for row in rows}
|
||||
matcher = re.compile(r"^m(\d\d\d)_")
|
||||
|
||||
async def run_migration(db, migrations_module):
|
||||
db_name = migrations_module.__name__.split(".")[-2]
|
||||
for key, migrate 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}")
|
||||
await migrate(db)
|
||||
await conn.execute(
|
||||
"INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)",
|
||||
(db_name, version),
|
||||
)
|
||||
|
||||
await run_migration(conn, core_migrations)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
try:
|
||||
ext_migrations = importlib.import_module(
|
||||
f"lnbits.extensions.{ext.code}.migrations"
|
||||
)
|
||||
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
|
||||
await run_migration(ext_db, ext_migrations)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
||||
)
|
||||
+15
-3
@@ -1,9 +1,21 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("database")
|
||||
|
||||
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))
|
||||
|
||||
+309
-136
@@ -1,9 +1,13 @@
|
||||
from typing import List, Optional
|
||||
import json
|
||||
import datetime
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from lnbits.db import open_db
|
||||
from lnbits import bolt11
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME
|
||||
|
||||
from . import db
|
||||
from .models import User, Wallet, Payment
|
||||
|
||||
|
||||
@@ -11,168 +15,280 @@ 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,))
|
||||
async def create_account(conn: Optional[Connection] = None) -> User:
|
||||
user_id = uuid4().hex
|
||||
await (conn or db).execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
|
||||
|
||||
new_account = get_account(user_id=user_id)
|
||||
new_account = await get_account(user_id=user_id, conn=conn)
|
||||
assert new_account, "Newly created account couldn't be retrieved"
|
||||
|
||||
return new_account
|
||||
|
||||
|
||||
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,))
|
||||
async def get_account(
|
||||
user_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[User]:
|
||||
row = await (conn or 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,))
|
||||
async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[User]:
|
||||
user = await (conn or 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 = await (conn or db).fetchall(
|
||||
"SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,)
|
||||
)
|
||||
wallets = await (conn or 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]}})
|
||||
User(
|
||||
**{
|
||||
**user,
|
||||
**{
|
||||
"extensions": [e[0] for e in extensions],
|
||||
"wallets": [Wallet(**w) for w in wallets],
|
||||
},
|
||||
}
|
||||
)
|
||||
if user
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
async def update_user_extension(
|
||||
*, user_id: str, extension: str, active: int, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO extensions (user, extension, active)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(user_id, extension, active),
|
||||
)
|
||||
|
||||
|
||||
# wallets
|
||||
# -------
|
||||
|
||||
|
||||
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),
|
||||
)
|
||||
async def create_wallet(
|
||||
*,
|
||||
user_id: str,
|
||||
wallet_name: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Wallet:
|
||||
wallet_id = uuid4().hex
|
||||
await (conn or 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)
|
||||
new_wallet = await get_wallet(wallet_id=wallet_id, conn=conn)
|
||||
assert new_wallet, "Newly created wallet couldn't be retrieved"
|
||||
|
||||
return new_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),
|
||||
)
|
||||
async def delete_wallet(
|
||||
*, user_id: str, wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> None:
|
||||
await (conn or 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,),
|
||||
)
|
||||
async def get_wallet(
|
||||
wallet_id: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
row = await (conn or 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:
|
||||
check_field = "adminkey" if key_type == "admin" else "inkey"
|
||||
row = db.fetchone(
|
||||
f"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE {check_field} = ?
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
async def get_wallet_for_key(
|
||||
key: str, key_type: str = "invoice", conn: Optional[Connection] = None
|
||||
) -> Optional[Wallet]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE adminkey = ? OR inkey = ?
|
||||
""",
|
||||
(key, key),
|
||||
)
|
||||
|
||||
return Wallet(**row) if row else None
|
||||
if not row:
|
||||
return None
|
||||
|
||||
if key_type == "admin" and row["adminkey"] != key:
|
||||
return None
|
||||
|
||||
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(
|
||||
async def get_standalone_payment(
|
||||
checking_id_or_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Payment]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE checking_id = ? OR hash = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(checking_id_or_hash, checking_id_or_hash),
|
||||
)
|
||||
|
||||
return Payment.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_wallet_payment(
|
||||
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
|
||||
) -> Optional[Payment]:
|
||||
row = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet = ? AND hash = ?
|
||||
""",
|
||||
(wallet_id, payment_hash),
|
||||
)
|
||||
|
||||
return Payment.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_payments(
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
outgoing: bool = False,
|
||||
incoming: bool = False,
|
||||
since: Optional[int] = None,
|
||||
exclude_uncheckable: bool = False,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> List[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||
"""
|
||||
|
||||
args: List[Any] = []
|
||||
clause: List[str] = []
|
||||
|
||||
if since != None:
|
||||
clause.append("time > ?")
|
||||
args.append(since)
|
||||
|
||||
if wallet_id:
|
||||
clause.append("wallet = ?")
|
||||
args.append(wallet_id)
|
||||
|
||||
if complete and pending:
|
||||
pass
|
||||
elif complete:
|
||||
clause.append("((amount > 0 AND pending = 0) OR amount < 0)")
|
||||
elif pending:
|
||||
clause.append("pending = 1")
|
||||
else:
|
||||
pass
|
||||
|
||||
if outgoing and incoming:
|
||||
pass
|
||||
elif outgoing:
|
||||
clause.append("amount < 0")
|
||||
elif incoming:
|
||||
clause.append("amount > 0")
|
||||
else:
|
||||
pass
|
||||
|
||||
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
|
||||
clause.append("checking_id NOT LIKE 'temp_%'")
|
||||
clause.append("checking_id NOT LIKE 'internal_%'")
|
||||
|
||||
where = ""
|
||||
if clause:
|
||||
where = f"WHERE {' AND '.join(clause)}"
|
||||
|
||||
rows = await (conn or db).fetchall(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
{where}
|
||||
ORDER BY time DESC
|
||||
""",
|
||||
tuple(args),
|
||||
)
|
||||
|
||||
return [Payment.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_expired_invoices(
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
rows = await (conn or 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
|
||||
|
||||
await (conn or 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,),
|
||||
)
|
||||
|
||||
|
||||
@@ -180,29 +296,86 @@ 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
|
||||
async def create_payment(
|
||||
*,
|
||||
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,
|
||||
webhook: Optional[str] = None,
|
||||
conn: Optional[Connection] = 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),
|
||||
)
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
INSERT INTO apipayments
|
||||
(wallet, checking_id, bolt11, hash, preimage,
|
||||
amount, pending, memo, fee, extra, webhook)
|
||||
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,
|
||||
webhook,
|
||||
),
|
||||
)
|
||||
|
||||
new_payment = get_wallet_payment(wallet_id, checking_id)
|
||||
new_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
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,))
|
||||
async def update_payment_status(
|
||||
checking_id: str,
|
||||
pending: bool,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
await (conn or 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,))
|
||||
async def delete_payment(
|
||||
checking_id: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)
|
||||
)
|
||||
|
||||
|
||||
async def check_internal(
|
||||
payment_hash: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[str]:
|
||||
row = await (conn or 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"]
|
||||
|
||||
+72
-10
@@ -1,11 +1,22 @@
|
||||
from lnbits.db import open_db
|
||||
from sqlalchemy.exc import OperationalError # type: ignore
|
||||
|
||||
|
||||
def m001_initial(db):
|
||||
async def m000_create_migrations_table(db):
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE dbversions (
|
||||
db TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial LNbits tables.
|
||||
"""
|
||||
db.execute(
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -14,7 +25,7 @@ def m001_initial(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.execute(
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
user TEXT NOT NULL,
|
||||
@@ -25,7 +36,7 @@ def m001_initial(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.execute(
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -36,7 +47,7 @@ def m001_initial(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.execute(
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS apipayments (
|
||||
payhash TEXT NOT NULL,
|
||||
@@ -51,7 +62,8 @@ def m001_initial(db):
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.execute(
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE VIEW IF NOT EXISTS balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
@@ -70,6 +82,56 @@ def m001_initial(db):
|
||||
)
|
||||
|
||||
|
||||
def migrate():
|
||||
with open_db() as db:
|
||||
m001_initial(db)
|
||||
async 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:
|
||||
await db.execute("ALTER TABLE apipayments RENAME COLUMN payhash TO checking_id")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN hash TEXT")
|
||||
await db.execute("CREATE INDEX by_hash ON apipayments (hash)")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN preimage TEXT")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
|
||||
|
||||
import json
|
||||
|
||||
rows = await (await db.execute("SELECT * FROM apipayments")).fetchall()
|
||||
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) :]
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?, memo = ?
|
||||
WHERE checking_id = ? AND memo = ?
|
||||
""",
|
||||
(
|
||||
json.dumps({"tag": ext}),
|
||||
new,
|
||||
row["checking_id"],
|
||||
row["memo"],
|
||||
),
|
||||
)
|
||||
break
|
||||
except OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
# run twice in some environments.
|
||||
# catching errors like this won't be necessary in anymore now that we
|
||||
# keep track of db versions so no migration ever runs twice.
|
||||
pass
|
||||
|
||||
|
||||
async def m003_add_invoice_webhook(db):
|
||||
"""
|
||||
Special column for webhook endpoints that can be assigned
|
||||
to each different invoice.
|
||||
"""
|
||||
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook TEXT")
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN webhook_status TEXT")
|
||||
|
||||
+96
-16
@@ -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,
|
||||
)
|
||||
|
||||
async def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||
from .crud import get_wallet_payment
|
||||
|
||||
return get_wallet_payment(self.id, checking_id)
|
||||
return await get_wallet_payment(self.id, payment_hash)
|
||||
|
||||
def get_payments(self, *, include_all_pending: bool = False) -> List["Payment"]:
|
||||
from .crud import get_wallet_payments
|
||||
async 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_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 await get_payments(
|
||||
wallet_id=self.id,
|
||||
complete=complete,
|
||||
pending=pending,
|
||||
outgoing=outgoing,
|
||||
incoming=incoming,
|
||||
exclude_uncheckable=exclude_uncheckable,
|
||||
)
|
||||
|
||||
|
||||
class Payment(NamedTuple):
|
||||
@@ -52,6 +79,35 @@ class Payment(NamedTuple):
|
||||
fee: int
|
||||
memo: str
|
||||
time: int
|
||||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
extra: Dict
|
||||
wallet_id: str
|
||||
webhook: str
|
||||
webhook_status: int
|
||||
|
||||
@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"],
|
||||
webhook=row["webhook"],
|
||||
webhook_status=row["webhook_status"],
|
||||
)
|
||||
|
||||
@property
|
||||
def tag(self) -> Optional[str]:
|
||||
return self.extra.get("tag")
|
||||
|
||||
@property
|
||||
def msat(self) -> int:
|
||||
@@ -69,12 +125,36 @@ class Payment(NamedTuple):
|
||||
def is_out(self) -> bool:
|
||||
return self.amount < 0
|
||||
|
||||
def set_pending(self, pending: bool) -> None:
|
||||
@property
|
||||
def is_uncheckable(self) -> bool:
|
||||
return self.checking_id.startswith("temp_") or self.checking_id.startswith(
|
||||
"internal_"
|
||||
)
|
||||
|
||||
async def set_pending(self, pending: bool) -> None:
|
||||
from .crud import update_payment_status
|
||||
|
||||
update_payment_status(self.checking_id, pending)
|
||||
await update_payment_status(self.checking_id, pending)
|
||||
|
||||
def delete(self) -> None:
|
||||
async def check_pending(self) -> None:
|
||||
if self.is_uncheckable:
|
||||
return
|
||||
|
||||
if self.is_out:
|
||||
status = await WALLET.get_payment_status(self.checking_id)
|
||||
else:
|
||||
status = await WALLET.get_invoice_status(self.checking_id)
|
||||
|
||||
print(
|
||||
f" - checking '{'in' if self.is_in else 'out'}' {self.checking_id}: {status}"
|
||||
)
|
||||
|
||||
if self.is_out and status.failed:
|
||||
await self.delete()
|
||||
elif not status.pending:
|
||||
await self.set_pending(status.pending)
|
||||
|
||||
async def delete(self) -> None:
|
||||
from .crud import delete_payment
|
||||
|
||||
delete_payment(self.checking_id)
|
||||
await delete_payment(self.checking_id)
|
||||
|
||||
+246
-40
@@ -1,73 +1,279 @@
|
||||
from typing import Optional, Tuple
|
||||
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.db import Connection
|
||||
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 . import db
|
||||
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)
|
||||
async def create_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
amount: int, # in satoshis
|
||||
memo: str,
|
||||
description_hash: Optional[bytes] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
webhook: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Tuple[str, str]:
|
||||
invoice_memo = None if description_hash else memo
|
||||
storeable_memo = memo
|
||||
|
||||
ok, checking_id, payment_request, error_message = await 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)
|
||||
await 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,
|
||||
webhook=webhook,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
return checking_id, payment_request
|
||||
return invoice.payment_hash, payment_request
|
||||
|
||||
|
||||
def pay_invoice(*, wallet_id: str, bolt11: str, max_sat: Optional[int] = None) -> str:
|
||||
temp_id = f"temp_{urlsafe_short_hash()}"
|
||||
try:
|
||||
invoice = bolt11_decode(bolt11)
|
||||
async def pay_invoice(
|
||||
*,
|
||||
wallet_id: str,
|
||||
payment_request: str,
|
||||
max_sat: Optional[int] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
description: str = "",
|
||||
conn: Optional[Connection] = None,
|
||||
) -> str:
|
||||
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
|
||||
temp_id = f"temp_{urlsafe_short_hash()}"
|
||||
internal_id = f"internal_{urlsafe_short_hash()}"
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
if invoice.amount_msat == 0:
|
||||
raise ValueError("Amountless invoices not supported.")
|
||||
|
||||
if max_sat and invoice.amount_msat > max_sat * 1000:
|
||||
raise ValueError("Amount in invoice is too high.")
|
||||
|
||||
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
|
||||
create_payment(
|
||||
# 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,
|
||||
checking_id=temp_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=-invoice.amount_msat,
|
||||
fee=-fee_reserve,
|
||||
memo=temp_id,
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
wallet = get_wallet(wallet_id)
|
||||
assert wallet, "invalid wallet id"
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
|
||||
if internal_checking_id:
|
||||
# create a new payment from this wallet
|
||||
await create_payment(
|
||||
checking_id=internal_id,
|
||||
fee=0,
|
||||
pending=False,
|
||||
conn=conn,
|
||||
**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))
|
||||
await create_payment(
|
||||
checking_id=temp_id,
|
||||
fee=-fee_reserve,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
|
||||
# do the balance check
|
||||
wallet = await get_wallet(wallet_id, conn=conn)
|
||||
assert wallet
|
||||
if wallet.balance_msat < 0:
|
||||
raise PermissionError("Insufficient balance.")
|
||||
|
||||
ok, checking_id, fee_msat, error_message = WALLET.pay_invoice(bolt11)
|
||||
|
||||
if ok:
|
||||
create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=checking_id,
|
||||
amount=-invoice.amount_msat,
|
||||
fee=fee_msat,
|
||||
memo=invoice.description,
|
||||
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
|
||||
await update_payment_status(
|
||||
checking_id=internal_checking_id,
|
||||
pending=False,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
ok, error_message = False, str(e)
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_paid
|
||||
|
||||
delete_payment(temp_id)
|
||||
await internal_invoice_paid.send(internal_checking_id)
|
||||
else:
|
||||
# actually pay the external invoice
|
||||
payment: PaymentResponse = await WALLET.pay_invoice(payment_request)
|
||||
if payment.checking_id:
|
||||
await create_payment(
|
||||
checking_id=payment.checking_id,
|
||||
fee=payment.fee_msat,
|
||||
preimage=payment.preimage,
|
||||
pending=payment.ok == None,
|
||||
conn=conn,
|
||||
**payment_kwargs,
|
||||
)
|
||||
await delete_payment(temp_id, conn=conn)
|
||||
else:
|
||||
raise Exception(
|
||||
payment.error_message or "Failed to pay_invoice on backend."
|
||||
)
|
||||
|
||||
if not ok:
|
||||
raise Exception(error_message or "Unexpected backend error.")
|
||||
|
||||
return checking_id
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
def check_payment(*, checking_id: str) -> str:
|
||||
pass
|
||||
async def redeem_lnurl_withdraw(
|
||||
wallet_id: str,
|
||||
res: LnurlWithdrawResponse,
|
||||
memo: Optional[str] = None,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> None:
|
||||
_, payment_request = await create_invoice(
|
||||
wallet_id=wallet_id,
|
||||
amount=res.max_sats,
|
||||
memo=memo or res.default_description or "",
|
||||
extra={"tag": "lnurlwallet"},
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.get(
|
||||
res.callback.base,
|
||||
params={
|
||||
**res.callback.query_params,
|
||||
**{"k1": res.k1, "pr": payment_request},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def perform_lnurlauth(
|
||||
callback: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> Optional[LnurlErrorResponse]:
|
||||
cb = urlparse(callback)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def check_invoice_status(
|
||||
wallet_id: str,
|
||||
payment_hash: str,
|
||||
conn: Optional[Connection] = None,
|
||||
) -> PaymentStatus:
|
||||
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
|
||||
if not payment:
|
||||
return PaymentStatus(None)
|
||||
|
||||
return await WALLET.get_invoice_status(payment.checking_id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin]
|
||||
});
|
||||
})
|
||||
|
||||
@@ -3,23 +3,23 @@ new Vue({
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
disclaimerDialog: {
|
||||
disclaimerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
},
|
||||
walletName: ''
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createWallet: function () {
|
||||
LNbits.href.createWallet(this.walletName);
|
||||
LNbits.href.createWallet(this.walletName)
|
||||
},
|
||||
processing: function () {
|
||||
this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Processing...',
|
||||
icon: null
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
+473
-195
@@ -1,39 +1,45 @@
|
||||
Vue.component(VueQrcode.name, VueQrcode);
|
||||
Vue.use(VueQrcodeReader);
|
||||
/* globals windowMixin, decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart, decryptLnurlPayAES */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
function generateChart(canvas, payments) {
|
||||
var txs = [];
|
||||
var n = 0;
|
||||
var txs = []
|
||||
var n = 0
|
||||
var data = {
|
||||
labels: [],
|
||||
income: [],
|
||||
outcome: [],
|
||||
cumulative: []
|
||||
};
|
||||
}
|
||||
|
||||
_.each(payments.slice(0).sort(function (a, b) {
|
||||
return a.time - b.time;
|
||||
}), function (tx) {
|
||||
txs.push({
|
||||
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
|
||||
sat: tx.sat,
|
||||
});
|
||||
});
|
||||
_.each(
|
||||
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
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
_.each(_.groupBy(txs, 'hour'), function (value, day) {
|
||||
var income = _.reduce(value, function(memo, tx) {
|
||||
return (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;
|
||||
}, 0);
|
||||
n = n + income - outcome;
|
||||
data.labels.push(day);
|
||||
data.income.push(income);
|
||||
data.outcome.push(outcome);
|
||||
data.cumulative.push(n);
|
||||
});
|
||||
_.each(_.groupBy(txs, 'hour'), (value, day) => {
|
||||
var income = _.reduce(
|
||||
value,
|
||||
(memo, tx) => (tx.sat >= 0 ? memo + tx.sat : memo),
|
||||
0
|
||||
)
|
||||
var outcome = _.reduce(
|
||||
value,
|
||||
(memo, tx) => (tx.sat < 0 ? memo + Math.abs(tx.sat) : memo),
|
||||
0
|
||||
)
|
||||
n = n + income - outcome
|
||||
data.labels.push(day)
|
||||
data.income.push(income)
|
||||
data.outcome.push(outcome)
|
||||
data.cumulative.push(n)
|
||||
})
|
||||
|
||||
new Chart(canvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
@@ -44,7 +50,7 @@ function generateChart(canvas, payments) {
|
||||
data: data.cumulative,
|
||||
type: 'line',
|
||||
label: 'balance',
|
||||
backgroundColor: '#673ab7', // deep-purple
|
||||
backgroundColor: '#673ab7', // deep-purple
|
||||
borderColor: '#673ab7',
|
||||
borderWidth: 4,
|
||||
pointRadius: 3,
|
||||
@@ -55,14 +61,14 @@ function generateChart(canvas, payments) {
|
||||
type: 'bar',
|
||||
label: 'in',
|
||||
barPercentage: 0.75,
|
||||
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
|
||||
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
|
||||
},
|
||||
{
|
||||
data: data.outcome,
|
||||
type: 'bar',
|
||||
label: 'out',
|
||||
barPercentage: 0.75,
|
||||
backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink
|
||||
backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -72,18 +78,20 @@ function generateChart(canvas, payments) {
|
||||
},
|
||||
tooltips: {
|
||||
mode: 'index',
|
||||
intersect:false
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
xAxes: [{
|
||||
type: 'time',
|
||||
display: true,
|
||||
offset: true,
|
||||
time: {
|
||||
minUnit: 'hour',
|
||||
stepSize: 3
|
||||
xAxes: [
|
||||
{
|
||||
type: 'time',
|
||||
display: true,
|
||||
offset: true,
|
||||
time: {
|
||||
minUnit: 'hour',
|
||||
stepSize: 3
|
||||
}
|
||||
}
|
||||
}],
|
||||
]
|
||||
},
|
||||
// performance tweaks
|
||||
animation: {
|
||||
@@ -95,41 +103,72 @@ function generateChart(canvas, payments) {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
new Vue({
|
||||
el: '#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: 'date', align: 'left', label: 'Date', field: 'date', sortable: true},
|
||||
{name: 'sat', align: 'right', label: 'Amount (sat)', field: 'sat', sortable: true}
|
||||
{
|
||||
name: 'memo',
|
||||
align: 'left',
|
||||
label: 'Memo',
|
||||
field: 'memo'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
field: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'sat',
|
||||
align: 'right',
|
||||
label: 'Amount (sat)',
|
||||
field: 'sat',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'right',
|
||||
label: 'Fee (msat)',
|
||||
field: 'fee'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
@@ -142,216 +181,455 @@ 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;
|
||||
var q = this.paymentsTable.filter
|
||||
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)
|
||||
return LNbits.utils.search(this.payments, q)
|
||||
},
|
||||
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)
|
||||
return this.payments
|
||||
? _.where(this.payments, {pending: 1}).length > 0
|
||||
: false;
|
||||
: 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 () {
|
||||
generateChart(this.$refs.canvas, this.payments);
|
||||
});
|
||||
this.paymentsChart.show = true
|
||||
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);
|
||||
}, 10000);
|
||||
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)
|
||||
.then(function (response) {
|
||||
self.receive.status = 'success';
|
||||
self.receive.paymentReq = response.data.payment_request;
|
||||
this.receive.status = 'loading'
|
||||
LNbits.api
|
||||
.createInvoice(
|
||||
this.g.wallet,
|
||||
this.receive.data.amount,
|
||||
this.receive.data.memo,
|
||||
this.receive.lnurl && this.receive.lnurl.callback
|
||||
)
|
||||
.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.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
|
||||
})
|
||||
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) {
|
||||
self.fetchPayments();
|
||||
self.receive.show = false;
|
||||
clearInterval(self.receive.paymentChecker);
|
||||
this.onPaymentReceived(hash)
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
}).catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error);
|
||||
self.receive.status = 'pending';
|
||||
});
|
||||
})
|
||||
}, 5000)
|
||||
})
|
||||
.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 () {
|
||||
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 {
|
||||
var 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
|
||||
});
|
||||
return;
|
||||
caption: '400 BAD REQUEST'
|
||||
})
|
||||
this.parse.show = false
|
||||
return
|
||||
}
|
||||
|
||||
var cleanInvoice = {
|
||||
let cleanInvoice = {
|
||||
msat: invoice.human_readable_part.amount,
|
||||
sat: invoice.human_readable_part.amount / 1000,
|
||||
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') { cleanInvoice.hash = tag.value; }
|
||||
else if (tag.description == 'description') { cleanInvoice.description = tag.value; }
|
||||
else if (tag.description == 'expiry') {
|
||||
var expireDate = new Date((invoice.data.time_stamp + tag.value) * 1000);
|
||||
cleanInvoice.expireDate = Quasar.utils.date.formatDate(expireDate, 'YYYY-MM-DDTHH:mm:ss.SSSZ');
|
||||
cleanInvoice.expired = false; // TODO
|
||||
if (tag.description === 'payment_hash') {
|
||||
cleanInvoice.hash = tag.value
|
||||
} else if (tag.description === 'description') {
|
||||
cleanInvoice.description = tag.value
|
||||
} else if (tag.description === 'expiry') {
|
||||
var expireDate = new Date(
|
||||
(invoice.data.time_stamp + tag.value) * 1000
|
||||
)
|
||||
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
|
||||
expireDate,
|
||||
'YYYY-MM-DDTHH:mm:ss.SSSZ'
|
||||
)
|
||||
cleanInvoice.expired = false // TODO
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
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 () {
|
||||
LNbits.api.getPayment(self.g.wallet, response.data.checking_id).then(function (res) {
|
||||
if (res.data.paid) {
|
||||
self.send.show = false;
|
||||
clearInterval(self.send.paymentChecker);
|
||||
dismissPaymentMsg();
|
||||
self.fetchPayments();
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
}).catch(function (error) {
|
||||
dismissPaymentMsg();
|
||||
LNbits.utils.notifyApiError(error);
|
||||
});
|
||||
LNbits.api
|
||||
.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(this.g.wallet, response.data.payment_hash)
|
||||
.then(res => {
|
||||
if (res.data.paid) {
|
||||
this.parse.show = false
|
||||
clearInterval(this.parse.paymentChecker)
|
||||
dismissPaymentMsg()
|
||||
this.fetchPayments()
|
||||
this.fetchBalance()
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
.catch(err => {
|
||||
dismissPaymentMsg()
|
||||
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 () {
|
||||
LNbits.href.deleteWallet(walletId, user);
|
||||
});
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this wallet?')
|
||||
.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) {
|
||||
return LNbits.map.payment(obj);
|
||||
}).sort(function (a, b) {
|
||||
return b.time - a.time;
|
||||
});
|
||||
});
|
||||
fetchPayments: function () {
|
||||
return LNbits.api.getPayments(this.g.wallet).then(response => {
|
||||
this.payments = response.data
|
||||
.map(obj => {
|
||||
return LNbits.map.payment(obj)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return b.time - a.time
|
||||
})
|
||||
})
|
||||
},
|
||||
checkPendingPayments: function () {
|
||||
var dismissMsg = this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Checking pending transactions...',
|
||||
icon: null
|
||||
});
|
||||
|
||||
this.fetchPayments(true).then(function () {
|
||||
dismissMsg();
|
||||
});
|
||||
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
|
||||
])
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments);
|
||||
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'payments': function () {
|
||||
EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance]);
|
||||
payments: function () {
|
||||
this.fetchBalance()
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.fetchPayments();
|
||||
setTimeout(this.checkPendingPayments(), 1200);
|
||||
this.fetchBalance()
|
||||
this.fetchPayments()
|
||||
},
|
||||
mounted: function () {
|
||||
if (this.$refs.disclaimer && !this.$q.localStorage.getItem('lnbits.disclaimerShown')) {
|
||||
this.disclaimerDialog.show = true;
|
||||
this.$q.localStorage.set('lnbits.disclaimerShown', true);
|
||||
// show disclaimer
|
||||
if (
|
||||
this.$refs.disclaimer &&
|
||||
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
|
||||
) {
|
||||
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,58 @@
|
||||
import trio # type: ignore
|
||||
import httpx
|
||||
from typing import List
|
||||
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
from . import db
|
||||
from .models import Payment
|
||||
|
||||
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:
|
||||
# send information to sse channel
|
||||
await dispatch_sse(payment)
|
||||
|
||||
# dispatch webhook
|
||||
if payment.webhook and not payment.webhook_status:
|
||||
await dispatch_webhook(payment)
|
||||
|
||||
|
||||
async def dispatch_sse(payment: Payment):
|
||||
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)
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
async with httpx.AsyncClient() as client:
|
||||
data = payment._asdict()
|
||||
try:
|
||||
r = await client.post(
|
||||
payment.webhook,
|
||||
json=data,
|
||||
timeout=40,
|
||||
)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET webhook_status = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(status, payment.payment_hash),
|
||||
)
|
||||
@@ -4,22 +4,70 @@
|
||||
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>, "webhook":
|
||||
<url: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 +75,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/arcbtc/lnbits" target="_blank" rel="noopener">View project in GitHub</q-btn>
|
||||
<q-btn outline
|
||||
color="grey"
|
||||
type="a" href="https://paywall.link/to/f4e4e" 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,556 @@
|
||||
{% 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.1.1/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.24.0/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.1.1/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>
|
||||
<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="settings_cell"
|
||||
label="Export to Phone with QR Code"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>This QR code contains your wallet URL with full access. You can scan it from your phone to open your wallet from there.</p>
|
||||
<qrcode
|
||||
:value="'{{request.url_root}}'+'wallet?usr={{user.id}}&wal={{wallet.id}}'"
|
||||
:options="{width:240}"
|
||||
></qrcode>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<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="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 %}
|
||||
|
||||
+366
-50
@@ -1,89 +1,405 @@
|
||||
from flask import g, jsonify, request
|
||||
import trio # type: ignore
|
||||
import json
|
||||
import lnurl # type: ignore
|
||||
import httpx
|
||||
from urllib.parse import urlparse, urlunparse, urlencode, parse_qs, ParseResult
|
||||
from quart import g, jsonify, 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.helpers import Status
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
from ..services import create_invoice, pay_invoice
|
||||
from .. import core_app, db
|
||||
from ..services import create_invoice, pay_invoice, perform_lnurlauth
|
||||
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():
|
||||
if "check_pending" in request.args:
|
||||
g.wallet.delete_expired_payments()
|
||||
|
||||
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)
|
||||
|
||||
return jsonify(g.wallet.get_payments()), Status.OK
|
||||
async def api_payments():
|
||||
return jsonify(await 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},
|
||||
"extra": {"type": "dict", "nullable": True, "required": False},
|
||||
"webhook": {"type": "string", "empty": False, "required": False},
|
||||
}
|
||||
)
|
||||
def api_payments_create_invoice():
|
||||
try:
|
||||
checking_id, payment_request = create_invoice(
|
||||
wallet_id=g.wallet.id, amount=g.data["amount"], memo=g.data["memo"]
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), Status.INTERNAL_SERVER_ERROR
|
||||
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"]
|
||||
|
||||
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), Status.CREATED
|
||||
async with db.connect() as conn:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=g.wallet.id,
|
||||
amount=g.data["amount"],
|
||||
memo=memo,
|
||||
description_hash=description_hash,
|
||||
extra=g.data.get("extra"),
|
||||
webhook=g.data.get("webhook"),
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
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():
|
||||
@api_validate_post_request(
|
||||
schema={"bolt11": {"type": "string", "empty": False, "required": True}}
|
||||
)
|
||||
async def api_payments_pay_invoice():
|
||||
try:
|
||||
checking_id = pay_invoice(wallet_id=g.wallet.id, bolt11=g.data["bolt11"])
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=g.wallet.id,
|
||||
payment_request=g.data["bolt11"],
|
||||
)
|
||||
except ValueError as e:
|
||||
return jsonify({"message": str(e)}), Status.BAD_REQUEST
|
||||
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
|
||||
except PermissionError as e:
|
||||
return jsonify({"message": str(e)}), Status.FORBIDDEN
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), Status.INTERNAL_SERVER_ERROR
|
||||
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
|
||||
except Exception as exc:
|
||||
raise exc
|
||||
|
||||
return jsonify({"checking_id": checking_id}), Status.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,
|
||||
)
|
||||
|
||||
extra = {}
|
||||
|
||||
if params.get("successAction"):
|
||||
extra["success_action"] = params["successAction"]
|
||||
if g.data["comment"]:
|
||||
extra["comment"] = g.data["comment"]
|
||||
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=g.wallet.id,
|
||||
payment_request=params["pr"],
|
||||
description=g.data.get("description", ""),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
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 = await g.wallet.get_payment(payment_hash)
|
||||
|
||||
if not payment:
|
||||
return jsonify({"message": "Payment does not exist."}), Status.NOT_FOUND
|
||||
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
|
||||
elif not payment.pending:
|
||||
return jsonify({"paid": True}), Status.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
|
||||
await payment.check_pending()
|
||||
except Exception:
|
||||
return jsonify({"paid": False}), Status.OK
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
if is_paid:
|
||||
payment.set_pending(False)
|
||||
return jsonify({"paid": True}), Status.OK
|
||||
return (
|
||||
jsonify({"paid": not payment.pending, "preimage": payment.preimage}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
return jsonify({"paid": False}), Status.OK
|
||||
|
||||
@core_app.route("/api/v1/payments/sse", methods=["GET"])
|
||||
@api_check_wallet_key("invoice", accept_querystring=True)
|
||||
async def api_payments_sse():
|
||||
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,21 @@
|
||||
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 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.core import core_app, db
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.helpers import Status
|
||||
from lnbits.settings import SERVICE_FEE
|
||||
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
@@ -13,39 +24,50 @@ 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)
|
||||
|
||||
if extension_to_enable and extension_to_disable:
|
||||
abort(Status.BAD_REQUEST, "You can either `enable` or `disable` an extension.")
|
||||
abort(
|
||||
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
|
||||
)
|
||||
|
||||
if extension_to_enable:
|
||||
update_user_extension(user_id=g.user.id, extension=extension_to_enable, active=1)
|
||||
await update_user_extension(
|
||||
user_id=g.user.id, extension=extension_to_enable, active=1
|
||||
)
|
||||
elif extension_to_disable:
|
||||
update_user_extension(user_id=g.user.id, extension=extension_to_disable, active=0)
|
||||
await 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=await 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)
|
||||
@@ -58,38 +80,88 @@ def wallet():
|
||||
# nothing: create everything
|
||||
|
||||
if not user_id:
|
||||
user = get_user(create_account().id)
|
||||
user = await get_user((await create_account()).id)
|
||||
else:
|
||||
user = get_user(user_id) or abort(Status.NOT_FOUND, "User does not exist.")
|
||||
user = await get_user(user_id)
|
||||
if not user:
|
||||
abort(HTTPStatus.NOT_FOUND, "User does not exist.")
|
||||
return
|
||||
|
||||
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:
|
||||
wallet = user.wallets[0]
|
||||
else:
|
||||
wallet = create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
|
||||
|
||||
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
|
||||
|
||||
if wallet_id not in user.wallet_ids:
|
||||
abort(Status.FORBIDDEN, "Not your wallet.")
|
||||
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
|
||||
|
||||
if wallet_id not in user_wallet_ids:
|
||||
abort(Status.FORBIDDEN, "Not your wallet.")
|
||||
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
else:
|
||||
delete_wallet(user_id=g.user.id, wallet_id=wallet_id)
|
||||
await delete_wallet(user_id=g.user.id, wallet_id=wallet_id)
|
||||
user_wallet_ids.remove(wallet_id)
|
||||
|
||||
if user_wallet_ids:
|
||||
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,
|
||||
)
|
||||
|
||||
async with db.connect() as conn:
|
||||
account = await create_account(conn=conn)
|
||||
user = await get_user(account.id, conn=conn)
|
||||
wallet = await create_wallet(user_id=user.id, conn=conn)
|
||||
|
||||
g.nursery.start_soon(
|
||||
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,57 +0,0 @@
|
||||
import requests
|
||||
|
||||
from flask import abort, redirect, request, url_for
|
||||
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.helpers import Status
|
||||
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"), response_class=LnurlWithdrawResponse)
|
||||
except LnurlException:
|
||||
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
|
||||
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(Status.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(Status.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
|
||||
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))
|
||||
+53
-31
@@ -1,42 +1,64 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import trio
|
||||
from contextlib import asynccontextmanager
|
||||
from sqlalchemy import create_engine # type: ignore
|
||||
from sqlalchemy_aio import TRIO_STRATEGY # type: ignore
|
||||
from sqlalchemy_aio.base import AsyncConnection
|
||||
|
||||
from .settings import LNBITS_DATA_FOLDER
|
||||
|
||||
|
||||
class Connection:
|
||||
def __init__(self, conn: AsyncConnection):
|
||||
self.conn = conn
|
||||
|
||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||
result = await self.conn.execute(query, values)
|
||||
return await result.fetchall()
|
||||
|
||||
async def fetchone(self, query: str, values: tuple = ()):
|
||||
result = await self.conn.execute(query, values)
|
||||
row = await result.fetchone()
|
||||
await result.close()
|
||||
return row
|
||||
|
||||
async def execute(self, query: str, values: tuple = ()):
|
||||
return await self.conn.execute(query, values)
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path: str):
|
||||
self.path = db_path
|
||||
self.connection = sqlite3.connect(db_path)
|
||||
self.connection.row_factory = sqlite3.Row
|
||||
self.cursor = self.connection.cursor()
|
||||
def __init__(self, db_name: str):
|
||||
self.db_name = db_name
|
||||
db_path = os.path.join(LNBITS_DATA_FOLDER, f"{db_name}.sqlite3")
|
||||
self.engine = create_engine(f"sqlite:///{db_path}", strategy=TRIO_STRATEGY)
|
||||
self.lock = trio.StrictFIFOLock()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
@asynccontextmanager
|
||||
async def connect(self):
|
||||
await self.lock.acquire()
|
||||
try:
|
||||
async with self.engine.connect() as conn:
|
||||
async with conn.begin():
|
||||
yield Connection(conn)
|
||||
finally:
|
||||
self.lock.release()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.cursor.close()
|
||||
self.connection.close()
|
||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||
async with self.connect() as conn:
|
||||
result = await conn.execute(query, values)
|
||||
return await result.fetchall()
|
||||
|
||||
def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||
"""Given a query, return cursor.fetchall() rows."""
|
||||
self.cursor.execute(query, values)
|
||||
return self.cursor.fetchall()
|
||||
async def fetchone(self, query: str, values: tuple = ()):
|
||||
async with self.connect() as conn:
|
||||
result = await conn.execute(query, values)
|
||||
row = await result.fetchone()
|
||||
await result.close()
|
||||
return row
|
||||
|
||||
def fetchone(self, query: str, values: tuple = ()):
|
||||
self.cursor.execute(query, values)
|
||||
return self.cursor.fetchone()
|
||||
async def execute(self, query: str, values: tuple = ()):
|
||||
async with self.connect() as conn:
|
||||
return await conn.execute(query, values)
|
||||
|
||||
def execute(self, query: str, values: tuple = ()) -> None:
|
||||
"""Given a query, cursor.execute() it."""
|
||||
self.cursor.execute(query, values)
|
||||
self.connection.commit()
|
||||
|
||||
|
||||
def open_db(db_name: str = "database") -> Database:
|
||||
db_path = os.path.join(LNBITS_DATA_FOLDER, f"{db_name}.sqlite3")
|
||||
return Database(db_path=db_path)
|
||||
|
||||
|
||||
def open_ext_db(extension_name: str) -> Database:
|
||||
return open_db(f"ext_{extension_name}")
|
||||
@asynccontextmanager
|
||||
async def reuse_conn(self, conn: Connection):
|
||||
yield conn
|
||||
|
||||
+44
-22
@@ -1,26 +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 typing import List, Union
|
||||
from uuid import UUID
|
||||
|
||||
from lnbits.core.crud import get_user, get_wallet_for_key
|
||||
from .helpers import Status
|
||||
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 = await get_wallet_for_key(key_value, key_type)
|
||||
except KeyError:
|
||||
return jsonify({"message": "`X-Api-Key` header missing."}), Status.BAD_REQUEST
|
||||
return (
|
||||
jsonify({"message": "`X-Api-Key` header missing."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not g.wallet:
|
||||
return jsonify({"message": "Wrong keys."}), Status.UNAUTHORIZED
|
||||
return jsonify({"message": "Wrong keys."}), HTTPStatus.UNAUTHORIZED
|
||||
|
||||
return view(**kwargs)
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@@ -30,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`."}), Status.BAD_REQUEST
|
||||
return (
|
||||
jsonify({"message": "Content-Type must be `application/json`."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
v = Validator(schema)
|
||||
g.data = {key: request.json[key] 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}"}), Status.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
|
||||
|
||||
@@ -50,32 +62,42 @@ def api_validate_post_request(*, schema: dict):
|
||||
def check_user_exists(param: str = "usr"):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
g.user = get_user(request.args.get(param, type=str)) or abort(Status.NOT_FOUND, "User not found.")
|
||||
return view(**kwargs)
|
||||
async def wrapped_view(**kwargs):
|
||||
g.user = await get_user(request.args.get(param, type=str)) or abort(
|
||||
HTTPStatus.NOT_FOUND, "User does not exist."
|
||||
)
|
||||
|
||||
if LNBITS_ALLOWED_USERS and g.user.id not in LNBITS_ALLOWED_USERS:
|
||||
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
|
||||
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4):
|
||||
def validate_uuids(
|
||||
params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4
|
||||
):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
query_params = {param: request.args.get(param, type=str) for param in params}
|
||||
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():
|
||||
if not value and (required is True or (required and param in required)):
|
||||
abort(Status.BAD_REQUEST, f"`{param}` is required.")
|
||||
abort(HTTPStatus.BAD_REQUEST, f"`{param}` is required.")
|
||||
|
||||
if value:
|
||||
try:
|
||||
UUID(value, version=version)
|
||||
except ValueError:
|
||||
abort(Status.BAD_REQUEST, f"`{param}` is not a valid UUID.")
|
||||
abort(HTTPStatus.BAD_REQUEST, f"`{param}` is not a valid UUID.")
|
||||
|
||||
return view(**kwargs)
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_amilk")
|
||||
|
||||
amilk_ext: Blueprint = Blueprint("amilk", __name__, static_folder="static", template_folder="templates")
|
||||
amilk_ext: Blueprint = Blueprint(
|
||||
"amilk", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -2,43 +2,41 @@ from base64 import urlsafe_b64encode
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
from . import db
|
||||
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')
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO amilks (id, wallet, lnurl, atime, amount)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(amilk_id, wallet_id, lnurl, atime, amount),
|
||||
)
|
||||
async def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk:
|
||||
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO amilks (id, wallet, lnurl, atime, amount)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(amilk_id, wallet_id, lnurl, atime, amount),
|
||||
)
|
||||
|
||||
return get_amilk(amilk_id)
|
||||
amilk = await get_amilk(amilk_id)
|
||||
assert amilk, "Newly created amilk_id couldn't be retrieved"
|
||||
return amilk
|
||||
|
||||
|
||||
def get_amilk(amilk_id: str) -> Optional[AMilk]:
|
||||
with open_ext_db("amilk") as db:
|
||||
row = db.fetchone("SELECT * FROM amilks WHERE id = ?", (amilk_id,))
|
||||
|
||||
async def get_amilk(amilk_id: str) -> Optional[AMilk]:
|
||||
row = await db.fetchone("SELECT * FROM amilks WHERE id = ?", (amilk_id,))
|
||||
return AMilk(**row) if row else None
|
||||
|
||||
|
||||
def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
|
||||
async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
with open_ext_db("amilk") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [AMilk(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_amilk(amilk_id: str) -> None:
|
||||
with open_ext_db("amilk") as db:
|
||||
db.execute("DELETE FROM amilks WHERE id = ?", (amilk_id,))
|
||||
async def delete_amilk(amilk_id: str) -> None:
|
||||
await db.execute("DELETE FROM amilks WHERE id = ?", (amilk_id,))
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from lnbits.db import open_ext_db
|
||||
|
||||
|
||||
def m001_initial(db):
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial amilks table.
|
||||
"""
|
||||
db.execute("""
|
||||
await 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)
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">Assistant Faucet Milker</h5>
|
||||
<p>Milking faucets with software, known as "assmilking", seems at first to be black-hat, although in fact there might be some unexplored use cases. An LNURL withdraw gives someone the right to pull funds, which can be done over time. An LNURL withdraw could be used outside of just faucets, to provide money streaming and repeat payments.<br/>Paste or scan an LNURL withdraw, enter the amount for the AMilk to pull and the frequency for it to be pulled.<br/>
|
||||
<small> Created by, <a href="https://github.com/benarc">Ben Arc</a></small></p>
|
||||
</q-card>
|
||||
</q-card-section>
|
||||
|
||||
</q-card-section></q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">Assistant Faucet Milker</h5>
|
||||
<p>
|
||||
Milking faucets with software, known as "assmilking", seems at first to
|
||||
be black-hat, although in fact there might be some unexplored use cases.
|
||||
An LNURL withdraw gives someone the right to pull funds, which can be
|
||||
done over time. An LNURL withdraw could be used outside of just faucets,
|
||||
to provide money streaming and repeat payments.<br />Paste or scan an
|
||||
LNURL withdraw, enter the amount for the AMilk to pull and the frequency
|
||||
for it to be pulled.<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
@@ -1,230 +1,250 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="deep-purple" @click="amilkDialog.show = true"
|
||||
>New AMilk</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
{% from "macros.jinja" import window_vars with context %}
|
||||
|
||||
|
||||
{% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="deep-purple" @click="amilkDialog.show = true">New AMilk</q-btn>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">AMilks</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">AMilks</h5>
|
||||
</div>
|
||||
<q-table dense flat
|
||||
:data="amilks"
|
||||
row-key="id"
|
||||
:columns="amilksTable.columns"
|
||||
:pagination.sync="amilksTable.pagination">
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
>
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
:props="props"
|
||||
|
||||
>
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn flat dense size="xs" @click="deleteAMilk(props.row.id)" icon="cancel" color="pink"></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits Assistant Faucet Milker Extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "amilk/_api_docs.html" %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="amilkDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="createAMilk" class="q-gutter-md">
|
||||
<q-select filled dense emit-value v-model="amilkDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
|
||||
</q-select>
|
||||
<q-input filled dense
|
||||
v-model.trim="amilkDialog.data.lnurl"
|
||||
type="url"
|
||||
label="LNURL Withdraw"></q-input>
|
||||
<q-input filled dense
|
||||
v-model.number="amilkDialog.data.amount"
|
||||
type="number"
|
||||
label="Amount *"></q-input>
|
||||
<q-input filled dense
|
||||
v-model.trim="amilkDialog.data.atime"
|
||||
type="number"
|
||||
label="Hit frequency (secs)"
|
||||
placeholder="Frequency to be hit"></q-input>
|
||||
<q-btn unelevated
|
||||
color="deep-purple"
|
||||
:disable="amilkDialog.data.amount == null || amilkDialog.data.amount < 0 || amilkDialog.data.lnurl == null"
|
||||
type="submit">Create amilk</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="amilks"
|
||||
row-key="id"
|
||||
:columns="amilksTable.columns"
|
||||
:pagination.sync="amilksTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteAMilk(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ window_vars(user) }}
|
||||
<script>
|
||||
var mapAMilk = function (obj) {
|
||||
obj.date = Quasar.utils.date.formatDate(new Date(obj.time * 1000), 'YYYY-MM-DD HH:mm');
|
||||
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount);
|
||||
obj.wall = ['/amilk/', obj.id].join('');
|
||||
return obj;
|
||||
}
|
||||
<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 Assistant Faucet Milker Extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "amilk/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
amilks: [],
|
||||
amilksTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'lnurl', align: 'left', label: 'LNURL', field: 'lnurl'},
|
||||
{name: 'atime', align: 'left', label: 'Freq', field: 'atime'},
|
||||
{name: 'amount', align: 'left', label: 'Amount', field: 'amount'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
amilkDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
<q-dialog v-model="amilkDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="createAMilk" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="amilkDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="amilkDialog.data.lnurl"
|
||||
type="url"
|
||||
label="LNURL Withdraw"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="amilkDialog.data.amount"
|
||||
type="number"
|
||||
label="Amount *"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="amilkDialog.data.atime"
|
||||
type="number"
|
||||
label="Hit frequency (secs)"
|
||||
placeholder="Frequency to be hit"
|
||||
></q-input>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="amilkDialog.data.amount == null || amilkDialog.data.amount < 0 || amilkDialog.data.lnurl == null"
|
||||
type="submit"
|
||||
>Create amilk</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapAMilk = function (obj) {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.wall = ['/amilk/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
amilks: [],
|
||||
amilksTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'lnurl', align: 'left', label: 'LNURL', field: 'lnurl'},
|
||||
{name: 'atime', align: 'left', label: 'Freq', field: 'atime'},
|
||||
{name: 'amount', align: 'left', label: 'Amount', field: 'amount'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
amilkDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAMilks: function () {
|
||||
var self = this
|
||||
|
||||
|
||||
|
||||
getAMilks: function () {
|
||||
var self = this;
|
||||
|
||||
LNbits.api.request(
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/amilk/api/v1/amilk?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
).then(function (response) {
|
||||
)
|
||||
.then(function (response) {
|
||||
self.amilks = response.data.map(function (obj) {
|
||||
response.data.forEach(MILK);
|
||||
function MILK(item){
|
||||
|
||||
window.setInterval(function(){
|
||||
|
||||
|
||||
LNbits.api.request(
|
||||
'GET',
|
||||
'/amilk/api/v1/amilk/milk/' + item.id,
|
||||
"Lorem"
|
||||
).then(function (response) {
|
||||
self.amilks = response.data.map(function (obj) {
|
||||
return mapAMilk(obj);
|
||||
});
|
||||
});
|
||||
},
|
||||
item.atime*1000);
|
||||
|
||||
response.data.forEach(MILK)
|
||||
function MILK(item) {
|
||||
window.setInterval(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/amilk/api/v1/amilk/milk/' + item.id,
|
||||
'Lorem'
|
||||
)
|
||||
.then(function (response) {
|
||||
self.amilks = response.data.map(function (obj) {
|
||||
return mapAMilk(obj)
|
||||
})
|
||||
})
|
||||
}, item.atime * 1000)
|
||||
}
|
||||
return mapAMilk(obj);
|
||||
});
|
||||
});
|
||||
},
|
||||
createAMilk: function () {
|
||||
var data = {
|
||||
lnurl: this.amilkDialog.data.lnurl,
|
||||
atime: parseInt(this.amilkDialog.data.atime),
|
||||
amount: this.amilkDialog.data.amount
|
||||
};
|
||||
var self = this;
|
||||
return mapAMilk(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
createAMilk: function () {
|
||||
var data = {
|
||||
lnurl: this.amilkDialog.data.lnurl,
|
||||
atime: parseInt(this.amilkDialog.data.atime),
|
||||
amount: this.amilkDialog.data.amount
|
||||
}
|
||||
var self = this
|
||||
|
||||
console.log(this.amilkDialog.data.wallet);
|
||||
console.log(this.amilkDialog.data.wallet)
|
||||
|
||||
LNbits.api.request(
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/amilk/api/v1/amilk',
|
||||
_.findWhere(this.g.user.wallets, {id: this.amilkDialog.data.wallet}).inkey,
|
||||
_.findWhere(this.g.user.wallets, {id: this.amilkDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
).then(function (response) {
|
||||
self.amilks.push(mapAMilk(response.data));
|
||||
self.amilkDialog.show = false;
|
||||
self.amilkDialog.data = {};
|
||||
}).catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error);
|
||||
});
|
||||
},
|
||||
deleteAMilk: function (amilkId) {
|
||||
var self = this;
|
||||
var amilk = _.findWhere(this.amilks, {id: amilkId});
|
||||
|
||||
LNbits.utils.confirmDialog(
|
||||
'Are you sure you want to delete this AMilk link?'
|
||||
).onOk(function () {
|
||||
LNbits.api.request(
|
||||
'DELETE',
|
||||
'/amilk/api/v1/amilks/' + amilkId,
|
||||
_.findWhere(self.g.user.wallets, {id: amilk.wallet}).inkey
|
||||
).then(function (response) {
|
||||
self.amilks = _.reject(self.amilks, function (obj) { return obj.id == amilkId; });
|
||||
}).catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error);
|
||||
});
|
||||
});
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.amilksTable.columns, this.amilks);
|
||||
}
|
||||
)
|
||||
.then(function (response) {
|
||||
self.amilks.push(mapAMilk(response.data))
|
||||
self.amilkDialog.show = false
|
||||
self.amilkDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getAMilks();
|
||||
}
|
||||
deleteAMilk: function (amilkId) {
|
||||
var self = this
|
||||
var amilk = _.findWhere(this.amilks, {id: amilkId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this AMilk link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/amilk/api/v1/amilks/' + amilkId,
|
||||
_.findWhere(self.g.user.wallets, {id: amilk.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.amilks = _.reject(self.amilks, function (obj) {
|
||||
return obj.id == amilkId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.amilksTable.columns, this.amilks)
|
||||
}
|
||||
});
|
||||
</script>
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getAMilks()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
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
|
||||
from lnbits.extensions.amilk import amilk_ext
|
||||
from lnbits.helpers import Status
|
||||
|
||||
from . import amilk_ext
|
||||
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):
|
||||
amilk = get_amilk(amilk_id) or abort(Status.NOT_FOUND, "AMilk does not exist.")
|
||||
async def wall(amilk_id):
|
||||
amilk = await get_amilk(amilk_id)
|
||||
if not amilk:
|
||||
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,94 +1,102 @@
|
||||
from flask import g, jsonify, request
|
||||
import httpx
|
||||
from quart import g, jsonify, request, abort
|
||||
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.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.helpers import Status
|
||||
from lnbits.core.services import create_invoice, check_invoice_status
|
||||
|
||||
from lnbits.extensions.amilk import amilk_ext
|
||||
from . 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
|
||||
|
||||
@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:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return jsonify([amilk._asdict() for amilk in get_amilks(wallet_ids)]), Status.OK
|
||||
return (
|
||||
jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
|
||||
def api_amilkit(amilk_id):
|
||||
milk = get_amilk(amilk_id)
|
||||
async def api_amilkit(amilk_id):
|
||||
milk = await get_amilk(amilk_id)
|
||||
memo = milk.id
|
||||
|
||||
try:
|
||||
withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse)
|
||||
except LnurlException:
|
||||
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
print(withdraw_res.max_sats)
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
|
||||
|
||||
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(
|
||||
withdraw_res.callback.base,
|
||||
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=milk.wallet,
|
||||
amount=withdraw_res.max_sats,
|
||||
memo=memo,
|
||||
extra={"tag": "amilk"},
|
||||
)
|
||||
|
||||
if not r.ok:
|
||||
|
||||
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
r = httpx.get(
|
||||
withdraw_res.callback.base,
|
||||
params={
|
||||
**withdraw_res.callback.query_params,
|
||||
**{"k1": withdraw_res.k1, "pr": payment_request},
|
||||
},
|
||||
)
|
||||
|
||||
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 = await check_invoice_status(milk.wallet, payment_hash)
|
||||
if invoice_status.paid:
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
else:
|
||||
return jsonify({"paid": False}), Status.OK
|
||||
break
|
||||
continue
|
||||
|
||||
return jsonify({"paid": True}), Status.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():
|
||||
amilk = create_amilk(wallet_id=g.wallet.id, lnurl=g.data["lnurl"], atime=g.data["atime"], amount=g.data["amount"])
|
||||
@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 = await create_amilk(
|
||||
wallet_id=g.wallet.id,
|
||||
lnurl=g.data["lnurl"],
|
||||
atime=g.data["atime"],
|
||||
amount=g.data["amount"],
|
||||
)
|
||||
|
||||
return jsonify(amilk._asdict()), Status.CREATED
|
||||
return jsonify(amilk._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
def api_amilk_delete(amilk_id):
|
||||
amilk = get_amilk(amilk_id)
|
||||
async def api_amilk_delete(amilk_id):
|
||||
amilk = await get_amilk(amilk_id)
|
||||
|
||||
if not amilk:
|
||||
return jsonify({"message": "Paywall does not exist."}), Status.NOT_FOUND
|
||||
return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if amilk.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your amilk."}), Status.FORBIDDEN
|
||||
return jsonify({"message": "Not your amilk."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_amilk(amilk_id)
|
||||
await delete_amilk(amilk_id)
|
||||
|
||||
return "", Status.NO_CONTENT
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Bleskomat Extension for lnbits
|
||||
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/).
|
||||
|
||||
|
||||
## Connect Your Bleskomat ATM
|
||||
|
||||
* Click the "Add Bleskomat" button on this page to begin.
|
||||
* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers.
|
||||
* Choose the fiat currency. This should match the fiat currency that your ATM accepts.
|
||||
* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds.
|
||||
* Set your ATM's fee percentage.
|
||||
* Click the "Done" button.
|
||||
* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM.
|
||||
* Copy the configuration file ("bleskomat.conf") to your ATM's SD card.
|
||||
* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card.
|
||||
|
||||
|
||||
## How Does It Work?
|
||||
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet.
|
||||
@@ -0,0 +1,12 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_bleskomat")
|
||||
|
||||
bleskomat_ext: Blueprint = Blueprint(
|
||||
"bleskomat", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
from .lnurl_api import * # noqa
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Bleskomat",
|
||||
"short_description": "Connect a Bleskomat ATM to an lnbits",
|
||||
"icon": "money",
|
||||
"contributors": ["chill117"]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import secrets
|
||||
import time
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
from . import db
|
||||
from .models import Bleskomat, BleskomatLnurl
|
||||
from .helpers import generate_bleskomat_lnurl_hash
|
||||
|
||||
|
||||
async def create_bleskomat(
|
||||
*,
|
||||
wallet_id: str,
|
||||
name: str,
|
||||
fiat_currency: str,
|
||||
exchange_rate_provider: str,
|
||||
fee: str,
|
||||
) -> Bleskomat:
|
||||
bleskomat_id = uuid4().hex
|
||||
api_key_id = secrets.token_hex(8)
|
||||
api_key_secret = secrets.token_hex(32)
|
||||
api_key_encoding = "hex"
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_id,
|
||||
wallet_id,
|
||||
api_key_id,
|
||||
api_key_secret,
|
||||
api_key_encoding,
|
||||
name,
|
||||
fiat_currency,
|
||||
exchange_rate_provider,
|
||||
fee,
|
||||
),
|
||||
)
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
assert bleskomat, "Newly created bleskomat couldn't be retrieved"
|
||||
return bleskomat
|
||||
|
||||
|
||||
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM bleskomats WHERE api_key_id = ?", (api_key_id,)
|
||||
)
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Bleskomat(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE bleskomats SET {q} WHERE id = ?", (*kwargs.values(), bleskomat_id)
|
||||
)
|
||||
row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
return Bleskomat(**row) if row else None
|
||||
|
||||
|
||||
async def delete_bleskomat(bleskomat_id: str) -> None:
|
||||
await db.execute("DELETE FROM bleskomats WHERE id = ?", (bleskomat_id,))
|
||||
|
||||
|
||||
async def create_bleskomat_lnurl(
|
||||
*, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1
|
||||
) -> BleskomatLnurl:
|
||||
bleskomat_lnurl_id = uuid4().hex
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
now = int(time.time())
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
bleskomat_lnurl_id,
|
||||
bleskomat.id,
|
||||
bleskomat.wallet,
|
||||
hash,
|
||||
tag,
|
||||
params,
|
||||
bleskomat.api_key_id,
|
||||
uses,
|
||||
uses,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
bleskomat_lnurl = await get_bleskomat_lnurl(secret)
|
||||
assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved"
|
||||
return bleskomat_lnurl
|
||||
|
||||
|
||||
async def get_bleskomat_lnurl(secret: str) -> BleskomatLnurl:
|
||||
hash = generate_bleskomat_lnurl_hash(secret)
|
||||
row = await db.fetchone("SELECT * FROM bleskomat_lnurls WHERE hash = ?", (hash,))
|
||||
return BleskomatLnurl(**row) if row else None
|
||||
@@ -0,0 +1,79 @@
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
|
||||
fiat_currencies = json.load(
|
||||
open(
|
||||
os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json"
|
||||
),
|
||||
"r",
|
||||
)
|
||||
)
|
||||
|
||||
exchange_rate_providers = {
|
||||
"bitfinex": {
|
||||
"name": "Bitfinex",
|
||||
"domain": "bitfinex.com",
|
||||
"api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}",
|
||||
"getter": lambda data, replacements: data["last_price"],
|
||||
},
|
||||
"bitstamp": {
|
||||
"name": "Bitstamp",
|
||||
"domain": "bitstamp.net",
|
||||
"api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/",
|
||||
"getter": lambda data, replacements: data["last"],
|
||||
},
|
||||
"coinbase": {
|
||||
"name": "Coinbase",
|
||||
"domain": "coinbase.com",
|
||||
"api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}",
|
||||
"getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]],
|
||||
},
|
||||
"coinmate": {
|
||||
"name": "CoinMate",
|
||||
"domain": "coinmate.io",
|
||||
"api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}",
|
||||
"getter": lambda data, replacements: data["data"]["last"],
|
||||
},
|
||||
"kraken": {
|
||||
"name": "Kraken",
|
||||
"domain": "kraken.com",
|
||||
"api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}",
|
||||
"getter": lambda data, replacements: data["result"][
|
||||
"XXBTZ" + replacements["TO"]
|
||||
]["c"][0],
|
||||
},
|
||||
}
|
||||
|
||||
exchange_rate_providers_serializable = {}
|
||||
for ref, exchange_rate_provider in exchange_rate_providers.items():
|
||||
exchange_rate_provider_serializable = {}
|
||||
for key, value in exchange_rate_provider.items():
|
||||
if not callable(value):
|
||||
exchange_rate_provider_serializable[key] = value
|
||||
exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable
|
||||
|
||||
|
||||
async def fetch_fiat_exchange_rate(currency: str, provider: str):
|
||||
|
||||
replacements = {
|
||||
"FROM": "BTC",
|
||||
"from": "btc",
|
||||
"TO": currency.upper(),
|
||||
"to": currency.lower(),
|
||||
}
|
||||
|
||||
url = exchange_rate_providers[provider]["api_url"]
|
||||
for key in replacements.keys():
|
||||
url = url.replace("{" + key + "}", replacements[key])
|
||||
|
||||
getter = exchange_rate_providers[provider]["getter"]
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
rate = float(getter(data, replacements))
|
||||
|
||||
return rate
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"AED": "United Arab Emirates Dirham",
|
||||
"AFN": "Afghan Afghani",
|
||||
"ALL": "Albanian Lek",
|
||||
"AMD": "Armenian Dram",
|
||||
"ANG": "Netherlands Antillean Gulden",
|
||||
"AOA": "Angolan Kwanza",
|
||||
"ARS": "Argentine Peso",
|
||||
"AUD": "Australian Dollar",
|
||||
"AWG": "Aruban Florin",
|
||||
"AZN": "Azerbaijani Manat",
|
||||
"BAM": "Bosnia and Herzegovina Convertible Mark",
|
||||
"BBD": "Barbadian Dollar",
|
||||
"BDT": "Bangladeshi Taka",
|
||||
"BGN": "Bulgarian Lev",
|
||||
"BHD": "Bahraini Dinar",
|
||||
"BIF": "Burundian Franc",
|
||||
"BMD": "Bermudian Dollar",
|
||||
"BND": "Brunei Dollar",
|
||||
"BOB": "Bolivian Boliviano",
|
||||
"BRL": "Brazilian Real",
|
||||
"BSD": "Bahamian Dollar",
|
||||
"BTN": "Bhutanese Ngultrum",
|
||||
"BWP": "Botswana Pula",
|
||||
"BYN": "Belarusian Ruble",
|
||||
"BYR": "Belarusian Ruble",
|
||||
"BZD": "Belize Dollar",
|
||||
"CAD": "Canadian Dollar",
|
||||
"CDF": "Congolese Franc",
|
||||
"CHF": "Swiss Franc",
|
||||
"CLF": "Unidad de Fomento",
|
||||
"CLP": "Chilean Peso",
|
||||
"CNH": "Chinese Renminbi Yuan Offshore",
|
||||
"CNY": "Chinese Renminbi Yuan",
|
||||
"COP": "Colombian Peso",
|
||||
"CRC": "Costa Rican Colón",
|
||||
"CUC": "Cuban Convertible Peso",
|
||||
"CVE": "Cape Verdean Escudo",
|
||||
"CZK": "Czech Koruna",
|
||||
"DJF": "Djiboutian Franc",
|
||||
"DKK": "Danish Krone",
|
||||
"DOP": "Dominican Peso",
|
||||
"DZD": "Algerian Dinar",
|
||||
"EGP": "Egyptian Pound",
|
||||
"ERN": "Eritrean Nakfa",
|
||||
"ETB": "Ethiopian Birr",
|
||||
"EUR": "Euro",
|
||||
"FJD": "Fijian Dollar",
|
||||
"FKP": "Falkland Pound",
|
||||
"GBP": "British Pound",
|
||||
"GEL": "Georgian Lari",
|
||||
"GGP": "Guernsey Pound",
|
||||
"GHS": "Ghanaian Cedi",
|
||||
"GIP": "Gibraltar Pound",
|
||||
"GMD": "Gambian Dalasi",
|
||||
"GNF": "Guinean Franc",
|
||||
"GTQ": "Guatemalan Quetzal",
|
||||
"GYD": "Guyanese Dollar",
|
||||
"HKD": "Hong Kong Dollar",
|
||||
"HNL": "Honduran Lempira",
|
||||
"HRK": "Croatian Kuna",
|
||||
"HTG": "Haitian Gourde",
|
||||
"HUF": "Hungarian Forint",
|
||||
"IDR": "Indonesian Rupiah",
|
||||
"ILS": "Israeli New Sheqel",
|
||||
"IMP": "Isle of Man Pound",
|
||||
"INR": "Indian Rupee",
|
||||
"IQD": "Iraqi Dinar",
|
||||
"ISK": "Icelandic Króna",
|
||||
"JEP": "Jersey Pound",
|
||||
"JMD": "Jamaican Dollar",
|
||||
"JOD": "Jordanian Dinar",
|
||||
"JPY": "Japanese Yen",
|
||||
"KES": "Kenyan Shilling",
|
||||
"KGS": "Kyrgyzstani Som",
|
||||
"KHR": "Cambodian Riel",
|
||||
"KMF": "Comorian Franc",
|
||||
"KRW": "South Korean Won",
|
||||
"KWD": "Kuwaiti Dinar",
|
||||
"KYD": "Cayman Islands Dollar",
|
||||
"KZT": "Kazakhstani Tenge",
|
||||
"LAK": "Lao Kip",
|
||||
"LBP": "Lebanese Pound",
|
||||
"LKR": "Sri Lankan Rupee",
|
||||
"LRD": "Liberian Dollar",
|
||||
"LSL": "Lesotho Loti",
|
||||
"LYD": "Libyan Dinar",
|
||||
"MAD": "Moroccan Dirham",
|
||||
"MDL": "Moldovan Leu",
|
||||
"MGA": "Malagasy Ariary",
|
||||
"MKD": "Macedonian Denar",
|
||||
"MMK": "Myanmar Kyat",
|
||||
"MNT": "Mongolian Tögrög",
|
||||
"MOP": "Macanese Pataca",
|
||||
"MRO": "Mauritanian Ouguiya",
|
||||
"MUR": "Mauritian Rupee",
|
||||
"MVR": "Maldivian Rufiyaa",
|
||||
"MWK": "Malawian Kwacha",
|
||||
"MXN": "Mexican Peso",
|
||||
"MYR": "Malaysian Ringgit",
|
||||
"MZN": "Mozambican Metical",
|
||||
"NAD": "Namibian Dollar",
|
||||
"NGN": "Nigerian Naira",
|
||||
"NIO": "Nicaraguan Córdoba",
|
||||
"NOK": "Norwegian Krone",
|
||||
"NPR": "Nepalese Rupee",
|
||||
"NZD": "New Zealand Dollar",
|
||||
"OMR": "Omani Rial",
|
||||
"PAB": "Panamanian Balboa",
|
||||
"PEN": "Peruvian Sol",
|
||||
"PGK": "Papua New Guinean Kina",
|
||||
"PHP": "Philippine Peso",
|
||||
"PKR": "Pakistani Rupee",
|
||||
"PLN": "Polish Złoty",
|
||||
"PYG": "Paraguayan Guaraní",
|
||||
"QAR": "Qatari Riyal",
|
||||
"RON": "Romanian Leu",
|
||||
"RSD": "Serbian Dinar",
|
||||
"RUB": "Russian Ruble",
|
||||
"RWF": "Rwandan Franc",
|
||||
"SAR": "Saudi Riyal",
|
||||
"SBD": "Solomon Islands Dollar",
|
||||
"SCR": "Seychellois Rupee",
|
||||
"SEK": "Swedish Krona",
|
||||
"SGD": "Singapore Dollar",
|
||||
"SHP": "Saint Helenian Pound",
|
||||
"SLL": "Sierra Leonean Leone",
|
||||
"SOS": "Somali Shilling",
|
||||
"SRD": "Surinamese Dollar",
|
||||
"SSP": "South Sudanese Pound",
|
||||
"STD": "São Tomé and Príncipe Dobra",
|
||||
"SVC": "Salvadoran Colón",
|
||||
"SZL": "Swazi Lilangeni",
|
||||
"THB": "Thai Baht",
|
||||
"TJS": "Tajikistani Somoni",
|
||||
"TMT": "Turkmenistani Manat",
|
||||
"TND": "Tunisian Dinar",
|
||||
"TOP": "Tongan Paʻanga",
|
||||
"TRY": "Turkish Lira",
|
||||
"TTD": "Trinidad and Tobago Dollar",
|
||||
"TWD": "New Taiwan Dollar",
|
||||
"TZS": "Tanzanian Shilling",
|
||||
"UAH": "Ukrainian Hryvnia",
|
||||
"UGX": "Ugandan Shilling",
|
||||
"USD": "US Dollar",
|
||||
"UYU": "Uruguayan Peso",
|
||||
"UZS": "Uzbekistan Som",
|
||||
"VEF": "Venezuelan Bolívar",
|
||||
"VES": "Venezuelan Bolívar Soberano",
|
||||
"VND": "Vietnamese Đồng",
|
||||
"VUV": "Vanuatu Vatu",
|
||||
"WST": "Samoan Tala",
|
||||
"XAF": "Central African Cfa Franc",
|
||||
"XAG": "Silver (Troy Ounce)",
|
||||
"XAU": "Gold (Troy Ounce)",
|
||||
"XCD": "East Caribbean Dollar",
|
||||
"XDR": "Special Drawing Rights",
|
||||
"XOF": "West African Cfa Franc",
|
||||
"XPD": "Palladium",
|
||||
"XPF": "Cfp Franc",
|
||||
"XPT": "Platinum",
|
||||
"YER": "Yemeni Rial",
|
||||
"ZAR": "South African Rand",
|
||||
"ZMW": "Zambian Kwacha",
|
||||
"ZWL": "Zimbabwean Dollar"
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
from http import HTTPStatus
|
||||
from binascii import unhexlify
|
||||
from typing import Dict
|
||||
from quart import url_for
|
||||
import urllib
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_hash(secret: str):
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{secret}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_signature(
|
||||
payload: str, api_key_secret: str, api_key_encoding: str = "hex"
|
||||
):
|
||||
if api_key_encoding == "hex":
|
||||
key = unhexlify(api_key_secret)
|
||||
elif api_key_encoding == "base64":
|
||||
key = base64.b64decode(api_key_secret)
|
||||
else:
|
||||
key = bytes(f"{api_key_secret}")
|
||||
return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
|
||||
# The secret is not randomly generated by the server.
|
||||
# Instead it is the hash of the API key ID and signature concatenated together.
|
||||
m = hashlib.sha256()
|
||||
m.update(f"{api_key_id}-{signature}".encode())
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
def get_callback_url():
|
||||
return url_for("bleskomat.api_bleskomat_lnurl", _external=True)
|
||||
|
||||
|
||||
def is_supported_lnurl_subprotocol(tag: str) -> bool:
|
||||
return tag == "withdrawRequest"
|
||||
|
||||
|
||||
class LnurlHttpError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "",
|
||||
http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
):
|
||||
self.message = message
|
||||
self.http_status = http_status
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class LnurlValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def prepare_lnurl_params(tag: str, query: Dict[str, str]):
|
||||
params = {}
|
||||
if not is_supported_lnurl_subprotocol(tag):
|
||||
raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"')
|
||||
if tag == "withdrawRequest":
|
||||
params["minWithdrawable"] = float(query["minWithdrawable"])
|
||||
params["maxWithdrawable"] = float(query["maxWithdrawable"])
|
||||
params["defaultDescription"] = query["defaultDescription"]
|
||||
if not params["minWithdrawable"] > 0:
|
||||
raise LnurlValidationError('"minWithdrawable" must be greater than zero')
|
||||
if not params["maxWithdrawable"] >= params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'"maxWithdrawable" must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
return params
|
||||
|
||||
|
||||
encode_uri_component_safe_chars = (
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()"
|
||||
)
|
||||
|
||||
|
||||
def query_to_signing_payload(query: Dict[str, str]) -> str:
|
||||
# Sort the query by key, then stringify it to create the payload.
|
||||
sorted_keys = sorted(query.keys(), key=str.lower)
|
||||
payload = []
|
||||
for key in sorted_keys:
|
||||
if not key == "signature":
|
||||
encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars)
|
||||
encoded_value = urllib.parse.quote(
|
||||
query[key], safe=encode_uri_component_safe_chars
|
||||
)
|
||||
payload.append(f"{encoded_key}={encoded_value}")
|
||||
return "&".join(payload)
|
||||
|
||||
|
||||
unshorten_rules = {
|
||||
"query": {"n": "nonce", "s": "signature", "t": "tag"},
|
||||
"tags": {
|
||||
"c": "channelRequest",
|
||||
"l": "login",
|
||||
"p": "payRequest",
|
||||
"w": "withdrawRequest",
|
||||
},
|
||||
"params": {
|
||||
"channelRequest": {"pl": "localAmt", "pp": "pushAmt"},
|
||||
"login": {},
|
||||
"payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"},
|
||||
"withdrawRequest": {
|
||||
"pn": "minWithdrawable",
|
||||
"px": "maxWithdrawable",
|
||||
"pd": "defaultDescription",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]:
|
||||
new_query = {}
|
||||
rules = unshorten_rules
|
||||
if "tag" in query:
|
||||
tag = query["tag"]
|
||||
elif "t" in query:
|
||||
tag = query["t"]
|
||||
else:
|
||||
raise LnurlValidationError('Missing required query parameter: "tag"')
|
||||
# Unshorten tag:
|
||||
if tag in rules["tags"]:
|
||||
long_tag = rules["tags"][tag]
|
||||
new_query["tag"] = long_tag
|
||||
tag = long_tag
|
||||
if not tag in rules["params"]:
|
||||
raise LnurlValidationError(f'Unknown tag: "{tag}"')
|
||||
for key in query:
|
||||
if key in rules["params"][tag]:
|
||||
short_param_key = key
|
||||
long_param_key = rules["params"][tag][short_param_key]
|
||||
if short_param_key in query:
|
||||
new_query[long_param_key] = query[short_param_key]
|
||||
else:
|
||||
new_query[long_param_key] = query[long_param_key]
|
||||
elif key in rules["query"]:
|
||||
# Unshorten general keys:
|
||||
short_key = key
|
||||
long_key = rules["query"][short_key]
|
||||
if not long_key in new_query:
|
||||
if short_key in query:
|
||||
new_query[long_key] = query[short_key]
|
||||
else:
|
||||
new_query[long_key] = query[long_key]
|
||||
else:
|
||||
# Keep unknown key/value pairs unchanged:
|
||||
new_query[key] = query[key]
|
||||
return new_query
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import math
|
||||
from quart import jsonify, request
|
||||
from http import HTTPStatus
|
||||
import traceback
|
||||
|
||||
from . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat_lnurl,
|
||||
get_bleskomat_by_api_key_id,
|
||||
get_bleskomat_lnurl,
|
||||
)
|
||||
|
||||
from .exchange_rates import (
|
||||
fetch_fiat_exchange_rate,
|
||||
)
|
||||
|
||||
from .helpers import (
|
||||
generate_bleskomat_lnurl_signature,
|
||||
generate_bleskomat_lnurl_secret,
|
||||
LnurlHttpError,
|
||||
LnurlValidationError,
|
||||
prepare_lnurl_params,
|
||||
query_to_signing_payload,
|
||||
unshorten_lnurl_query,
|
||||
)
|
||||
|
||||
|
||||
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
|
||||
@bleskomat_ext.route("/u", methods=["GET"])
|
||||
async def api_bleskomat_lnurl():
|
||||
try:
|
||||
query = request.args.to_dict()
|
||||
|
||||
# Unshorten query if "s" is used instead of "signature".
|
||||
if "s" in query:
|
||||
query = unshorten_lnurl_query(query)
|
||||
|
||||
if "signature" in query:
|
||||
|
||||
# Signature provided.
|
||||
# Use signature to verify that the URL was generated by an authorized device.
|
||||
# Later validate parameters, auto-generate LNURL, reply with LNURL response object.
|
||||
signature = query["signature"]
|
||||
|
||||
# The API key ID, nonce, and tag should be present in the query string.
|
||||
for field in ["id", "nonce", "tag"]:
|
||||
if not field in query:
|
||||
raise LnurlHttpError(
|
||||
f'Failed API key signature check: Missing "{field}"',
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
# URL signing scheme is described here:
|
||||
# https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme
|
||||
payload = query_to_signing_payload(query)
|
||||
api_key_id = query["id"]
|
||||
bleskomat = await get_bleskomat_by_api_key_id(api_key_id)
|
||||
if not bleskomat:
|
||||
raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST)
|
||||
api_key_secret = bleskomat.api_key_secret
|
||||
api_key_encoding = bleskomat.api_key_encoding
|
||||
expected_signature = generate_bleskomat_lnurl_signature(
|
||||
payload, api_key_secret, api_key_encoding
|
||||
)
|
||||
if signature != expected_signature:
|
||||
raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN)
|
||||
|
||||
# Signature is valid.
|
||||
# In the case of signed URLs, the secret is deterministic based on the API key ID and signature.
|
||||
secret = generate_bleskomat_lnurl_secret(api_key_id, signature)
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
try:
|
||||
tag = query["tag"]
|
||||
params = prepare_lnurl_params(tag, query)
|
||||
if "f" in query:
|
||||
rate = await fetch_fiat_exchange_rate(
|
||||
currency=query["f"],
|
||||
provider=bleskomat.exchange_rate_provider,
|
||||
)
|
||||
# Convert fee (%) to decimal:
|
||||
fee = float(bleskomat.fee) / 100
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable"]:
|
||||
amount_sats = int(
|
||||
math.floor((params[key] / rate) * 1e8)
|
||||
)
|
||||
fee_sats = int(math.floor(amount_sats * fee))
|
||||
amount_sats_less_fee = amount_sats - fee_sats
|
||||
# Convert to msats:
|
||||
params[key] = int(amount_sats_less_fee * 1e3)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST)
|
||||
# Create a new LNURL using the query parameters provided in the signed URL.
|
||||
params = json.JSONEncoder().encode(params)
|
||||
lnurl = await create_bleskomat_lnurl(
|
||||
bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1
|
||||
)
|
||||
|
||||
# Reply with LNURL response object.
|
||||
return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK
|
||||
|
||||
# No signature provided.
|
||||
# Treat as "action" callback.
|
||||
|
||||
if not "k1" in query:
|
||||
raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
secret = query["k1"]
|
||||
lnurl = await get_bleskomat_lnurl(secret)
|
||||
if not lnurl:
|
||||
raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if not lnurl.has_uses_remaining():
|
||||
raise LnurlHttpError(
|
||||
"Maximum number of uses already reached", HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
await lnurl.execute_action(query)
|
||||
except LnurlValidationError as e:
|
||||
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
|
||||
|
||||
except LnurlHttpError as e:
|
||||
return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return (
|
||||
jsonify({"status": "ERROR", "reason": "Unexpected error"}),
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return jsonify({"status": "OK"}), HTTPStatus.OK
|
||||
@@ -0,0 +1,37 @@
|
||||
async def m001_initial(db):
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bleskomats (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
api_key_secret TEXT NOT NULL,
|
||||
api_key_encoding TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
fiat_currency TEXT NOT NULL,
|
||||
exchange_rate_provider TEXT NOT NULL,
|
||||
fee TEXT NOT NULL,
|
||||
UNIQUE(api_key_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bleskomat_lnurls (
|
||||
id TEXT PRIMARY KEY,
|
||||
bleskomat TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
initial_uses INTEGER DEFAULT 1,
|
||||
remaining_uses INTEGER DEFAULT 0,
|
||||
created_time INTEGER,
|
||||
updated_time INTEGER,
|
||||
UNIQUE(hash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
import time
|
||||
from typing import NamedTuple, Dict
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.services import pay_invoice
|
||||
from . import db
|
||||
from .helpers import get_callback_url, LnurlValidationError
|
||||
|
||||
|
||||
class Bleskomat(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
api_key_id: str
|
||||
api_key_secret: str
|
||||
api_key_encoding: str
|
||||
name: str
|
||||
fiat_currency: str
|
||||
exchange_rate_provider: str
|
||||
fee: str
|
||||
|
||||
|
||||
class BleskomatLnurl(NamedTuple):
|
||||
id: str
|
||||
bleskomat: str
|
||||
wallet: str
|
||||
hash: str
|
||||
tag: str
|
||||
params: str
|
||||
api_key_id: str
|
||||
initial_uses: int
|
||||
remaining_uses: int
|
||||
created_time: int
|
||||
updated_time: int
|
||||
|
||||
def has_uses_remaining(self) -> bool:
|
||||
# When initial uses is 0 then the LNURL has unlimited uses.
|
||||
return self.initial_uses == 0 or self.remaining_uses > 0
|
||||
|
||||
def get_info_response_object(self, secret: str) -> Dict[str, str]:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
response = {"tag": tag}
|
||||
if tag == "withdrawRequest":
|
||||
for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]:
|
||||
response[key] = params[key]
|
||||
response["callback"] = get_callback_url()
|
||||
response["k1"] = secret
|
||||
return response
|
||||
|
||||
def validate_action(self, query: Dict[str, str]) -> None:
|
||||
tag = self.tag
|
||||
params = json.loads(self.params)
|
||||
# Perform tag-specific checks.
|
||||
if tag == "withdrawRequest":
|
||||
for field in ["pr"]:
|
||||
if not field in query:
|
||||
raise LnurlValidationError(f'Missing required parameter: "{field}"')
|
||||
# Check the bolt11 invoice(s) provided.
|
||||
pr = query["pr"]
|
||||
if "," in pr:
|
||||
raise LnurlValidationError("Multiple payment requests not supported")
|
||||
try:
|
||||
invoice = bolt11.decode(pr)
|
||||
except ValueError:
|
||||
raise LnurlValidationError(
|
||||
'Invalid parameter ("pr"): Lightning payment request expected'
|
||||
)
|
||||
if invoice.amount_msat < params["minWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be greater than or equal to "minWithdrawable"'
|
||||
)
|
||||
if invoice.amount_msat > params["maxWithdrawable"]:
|
||||
raise LnurlValidationError(
|
||||
'Amount in invoice must be less than or equal to "maxWithdrawable"'
|
||||
)
|
||||
else:
|
||||
raise LnurlValidationError(f'Unknown subprotocol: "{tag}"')
|
||||
|
||||
async def execute_action(self, query: Dict[str, str]):
|
||||
self.validate_action(query)
|
||||
used = False
|
||||
async with db.connect() as conn:
|
||||
if self.initial_uses > 0:
|
||||
used = await self.use(conn)
|
||||
if not used:
|
||||
raise LnurlValidationError("Maximum number of uses already reached")
|
||||
tag = self.tag
|
||||
if tag == "withdrawRequest":
|
||||
payment_hash = await pay_invoice(
|
||||
wallet_id=self.wallet,
|
||||
payment_request=query["pr"],
|
||||
)
|
||||
if not payment_hash:
|
||||
raise LnurlValidationError("Failed to pay invoice")
|
||||
|
||||
async def use(self, conn) -> bool:
|
||||
now = int(time.time())
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE bleskomat_lnurls
|
||||
SET remaining_uses = remaining_uses - 1, updated_time = ?
|
||||
WHERE id = ?
|
||||
AND remaining_uses > 0
|
||||
""",
|
||||
(now, self.id),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
@@ -0,0 +1,216 @@
|
||||
/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var mapBleskomat = function (obj) {
|
||||
obj._data = _.clone(obj)
|
||||
return obj
|
||||
}
|
||||
|
||||
var defaultValues = {
|
||||
name: 'My Bleskomat',
|
||||
fiat_currency: 'EUR',
|
||||
exchange_rate_provider: 'coinbase',
|
||||
fee: '0.00'
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
checker: null,
|
||||
bleskomats: [],
|
||||
bleskomatsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'api_key_id',
|
||||
align: 'left',
|
||||
label: 'API Key ID',
|
||||
field: 'api_key_id'
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
align: 'left',
|
||||
label: 'Name',
|
||||
field: 'name'
|
||||
},
|
||||
{
|
||||
name: 'fiat_currency',
|
||||
align: 'left',
|
||||
label: 'Fiat Currency',
|
||||
field: 'fiat_currency'
|
||||
},
|
||||
{
|
||||
name: 'exchange_rate_provider',
|
||||
align: 'left',
|
||||
label: 'Exchange Rate Provider',
|
||||
field: 'exchange_rate_provider'
|
||||
},
|
||||
{
|
||||
name: 'fee',
|
||||
align: 'left',
|
||||
label: 'Fee (%)',
|
||||
field: 'fee'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies),
|
||||
exchangeRateProviders: _.keys(
|
||||
window.bleskomat_vars.exchange_rate_providers
|
||||
),
|
||||
data: _.clone(defaultValues)
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedBleskomats: function () {
|
||||
return this.bleskomats.sort(function (a, b) {
|
||||
// Sort by API Key ID alphabetically.
|
||||
var apiKeyId_A = a.api_key_id.toLowerCase()
|
||||
var apiKeyId_B = b.api_key_id.toLowerCase()
|
||||
return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getBleskomats: function () {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/bleskomat/api/v1/bleskomats?all_wallets',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = response.data.map(function (obj) {
|
||||
return mapBleskomat(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
clearInterval(self.checker)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = _.clone(defaultValues)
|
||||
},
|
||||
exportConfigFile: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
var fieldToKey = {
|
||||
api_key_id: 'apiKey.id',
|
||||
api_key_secret: 'apiKey.key',
|
||||
api_key_encoding: 'apiKey.encoding',
|
||||
fiat_currency: 'fiatCurrency'
|
||||
}
|
||||
var lines = _.chain(bleskomat)
|
||||
.map(function (value, field) {
|
||||
var key = fieldToKey[field] || null
|
||||
return key ? [key, value].join('=') : null
|
||||
})
|
||||
.compact()
|
||||
.value()
|
||||
lines.push('callbackUrl=' + window.bleskomat_vars.callback_url)
|
||||
lines.push('shorten=true')
|
||||
var content = lines.join('\n')
|
||||
var status = Quasar.utils.exportFile(
|
||||
'bleskomat.conf',
|
||||
content,
|
||||
'text/plain'
|
||||
)
|
||||
if (status !== true) {
|
||||
Quasar.plugins.Notify.create({
|
||||
message: 'Browser denied file download...',
|
||||
color: 'negative',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
},
|
||||
openUpdateDialog: function (bleskomatId) {
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
this.formDialog.data = _.clone(bleskomat._data)
|
||||
this.formDialog.show = true
|
||||
},
|
||||
sendFormData: function () {
|
||||
var wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
if (data.id) {
|
||||
this.updateBleskomat(wallet, data)
|
||||
} else {
|
||||
this.createBleskomat(wallet, data)
|
||||
}
|
||||
},
|
||||
updateBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/bleskomat/api/v1/bleskomat/' + data.id,
|
||||
wallet.adminkey,
|
||||
_.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee')
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === data.id
|
||||
})
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createBleskomat: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data)
|
||||
.then(function (response) {
|
||||
self.bleskomats.push(mapBleskomat(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteBleskomat: function (bleskomatId) {
|
||||
var self = this
|
||||
var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId})
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'Are you sure you want to delete "' + bleskomat.name + '"?'
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/bleskomat/api/v1/bleskomat/' + bleskomatId,
|
||||
_.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.bleskomats = _.reject(self.bleskomats, function (obj) {
|
||||
return obj.id === bleskomatId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getBleskomats = this.getBleskomats
|
||||
getBleskomats()
|
||||
this.checker = setInterval(function () {
|
||||
getBleskomats()
|
||||
}, 20000)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Bleskomat Extension for lnbits"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This extension allows you to connect a Bleskomat ATM to an lnbits
|
||||
wallet. It will work with both the
|
||||
<a href="https://github.com/samotari/bleskomat"
|
||||
>open-source DIY Bleskomat ATM project</a
|
||||
>
|
||||
as well as the
|
||||
<a href="https://www.bleskomat.com/">commercial Bleskomat ATM</a>.
|
||||
</p>
|
||||
<h5 class="text-subtitle1 q-my-none">Connect Your Bleskomat ATM</h5>
|
||||
<div>
|
||||
<ol>
|
||||
<li>Click the "Add Bleskomat" button on this page to begin.</li>
|
||||
<li>
|
||||
Choose a wallet. This will be the wallet that is used to pay
|
||||
satoshis to your ATM customers.
|
||||
</li>
|
||||
<li>
|
||||
Choose the fiat currency. This should match the fiat currency that
|
||||
your ATM accepts.
|
||||
</li>
|
||||
<li>
|
||||
Pick an exchange rate provider. This is the API that will be used to
|
||||
query the fiat to satoshi exchange rate at the time your customer
|
||||
attempts to withdraw their funds.
|
||||
</li>
|
||||
<li>Set your ATM's fee percentage.</li>
|
||||
<li>Click the "Done" button.</li>
|
||||
<li>
|
||||
Find the new Bleskomat in the list and then click the export icon to
|
||||
download a new configuration file for your ATM.
|
||||
</li>
|
||||
<li>
|
||||
Copy the configuration file ("bleskomat.conf") to your ATM's SD
|
||||
card.
|
||||
</li>
|
||||
<li>
|
||||
Restart Your Bleskomat ATM. It should automatically reload the
|
||||
configurations from the SD card.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<h5 class="text-subtitle1 q-my-none">How does it work?</h5>
|
||||
<p>
|
||||
Since the Bleskomat ATMs are designed to be offline, a cryptographic
|
||||
signing scheme is used to verify that the URL was generated by an
|
||||
authorized device. When one of your customers inserts fiat money into
|
||||
the device, a signed URL (lnurl-withdraw) is created and displayed as a
|
||||
QR code. Your customer scans the QR code with their lnurl-supporting
|
||||
mobile app, their mobile app communicates with the web API of lnbits to
|
||||
verify the signature, the fiat currency amount is converted to sats, the
|
||||
customer accepts the withdrawal, and finally lnbits will pay the
|
||||
customer from your lnbits wallet.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,178 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
{% if bleskomat_vars %}
|
||||
window.bleskomat_vars = {{ bleskomat_vars | tojson | safe }}
|
||||
{% endif %}
|
||||
</script>
|
||||
<script src="/bleskomat/static/js/index.js"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
|
||||
>Add Bleskomat</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Bleskomats</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="sortedBleskomats"
|
||||
row-key="id"
|
||||
:columns="bleskomatsTable.columns"
|
||||
:pagination.sync="bleskomatsTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
icon="file_download"
|
||||
color="orange"
|
||||
@click="exportConfigFile(props.row.id)"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent"
|
||||
>Export Configuration</q-tooltip
|
||||
>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteBleskomat(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
>
|
||||
<q-tooltip content-class="bg-accent">Delete</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits Bleskomat extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "bleskomat/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="text"
|
||||
label="Name *"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.fiat_currency"
|
||||
:options="formDialog.fiatCurrencies"
|
||||
label="Fiat Currency *"
|
||||
>
|
||||
</q-select>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="formDialog.data.exchange_rate_provider"
|
||||
:options="formDialog.exchangeRateProviders"
|
||||
label="Exchange Rate Provider *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.fee"
|
||||
type="string"
|
||||
:default="0.00"
|
||||
label="Fee (%) *"
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
type="submit"
|
||||
>Update Bleskomat</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.name == null ||
|
||||
formDialog.data.fiat_currency == null ||
|
||||
formDialog.data.exchange_rate_provider == null ||
|
||||
formDialog.data.fee == null"
|
||||
type="submit"
|
||||
>Add Bleskomat</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import bleskomat_ext
|
||||
|
||||
from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies
|
||||
from .helpers import get_callback_url
|
||||
|
||||
|
||||
@bleskomat_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
bleskomat_vars = {
|
||||
"callback_url": get_callback_url(),
|
||||
"exchange_rate_providers": exchange_rate_providers_serializable,
|
||||
"fiat_currencies": fiat_currencies,
|
||||
}
|
||||
return await render_template(
|
||||
"bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
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 . import bleskomat_ext
|
||||
from .crud import (
|
||||
create_bleskomat,
|
||||
get_bleskomat,
|
||||
get_bleskomats,
|
||||
update_bleskomat,
|
||||
delete_bleskomat,
|
||||
)
|
||||
|
||||
from .exchange_rates import (
|
||||
exchange_rate_providers,
|
||||
fetch_fiat_exchange_rate,
|
||||
fiat_currencies,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomats():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
[bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["GET"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomat_retrieve(bleskomat_id):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
return jsonify(bleskomat._asdict()), HTTPStatus.OK
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"])
|
||||
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"fiat_currency": {
|
||||
"type": "string",
|
||||
"allowed": fiat_currencies.keys(),
|
||||
"required": True,
|
||||
},
|
||||
"exchange_rate_provider": {
|
||||
"type": "string",
|
||||
"allowed": exchange_rate_providers.keys(),
|
||||
"required": True,
|
||||
},
|
||||
"fee": {"type": ["string", "float", "number", "integer"], "required": True},
|
||||
}
|
||||
)
|
||||
async def api_bleskomat_create_or_update(bleskomat_id=None):
|
||||
try:
|
||||
fiat_currency = g.data["fiat_currency"]
|
||||
exchange_rate_provider = g.data["exchange_rate_provider"]
|
||||
await fetch_fiat_exchange_rate(
|
||||
currency=fiat_currency, provider=exchange_rate_provider
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"'
|
||||
}
|
||||
),
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
if bleskomat_id:
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
bleskomat = await update_bleskomat(bleskomat_id, **g.data)
|
||||
else:
|
||||
bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data)
|
||||
|
||||
return (
|
||||
jsonify(bleskomat._asdict()),
|
||||
HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@bleskomat_ext.route("/api/v1/bleskomat/<bleskomat_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("admin")
|
||||
async def api_bleskomat_delete(bleskomat_id):
|
||||
bleskomat = await get_bleskomat(bleskomat_id)
|
||||
|
||||
if not bleskomat or bleskomat.wallet != g.wallet.id:
|
||||
return (
|
||||
jsonify({"message": "Bleskomat configuration not found."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
await delete_bleskomat(bleskomat_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -0,0 +1,11 @@
|
||||
<h1>Example Extension</h1>
|
||||
<h2>*tagline*</h2>
|
||||
This is an example extension to help you organise and build you own.
|
||||
|
||||
Try to include an image
|
||||
<img src="https://i.imgur.com/9i4xcQB.png">
|
||||
|
||||
|
||||
<h2>If your extension has API endpoints, include useful ones here</h2>
|
||||
|
||||
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
|
||||
@@ -0,0 +1,12 @@
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_captcha")
|
||||
|
||||
captcha_ext: Blueprint = Blueprint(
|
||||
"captcha", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Captcha",
|
||||
"short_description": "Create captcha to stop spam",
|
||||
"icon": "block",
|
||||
"contributors": ["pseudozach"]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Captcha
|
||||
|
||||
|
||||
async def create_captcha(
|
||||
*,
|
||||
wallet_id: str,
|
||||
url: str,
|
||||
memo: str,
|
||||
description: Optional[str] = None,
|
||||
amount: int = 0,
|
||||
remembers: bool = True,
|
||||
) -> Captcha:
|
||||
captcha_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO captchas (id, wallet, url, memo, description, amount, remembers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(captcha_id, wallet_id, url, memo, description, amount, int(remembers)),
|
||||
)
|
||||
|
||||
captcha = await get_captcha(captcha_id)
|
||||
assert captcha, "Newly created captcha couldn't be retrieved"
|
||||
return captcha
|
||||
|
||||
|
||||
async def get_captcha(captcha_id: str) -> Optional[Captcha]:
|
||||
row = await db.fetchone("SELECT * FROM captchas WHERE id = ?", (captcha_id,))
|
||||
|
||||
return Captcha.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM captchas WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Captcha.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def delete_captcha(captcha_id: str) -> None:
|
||||
await db.execute("DELETE FROM captchas WHERE id = ?", (captcha_id,))
|
||||
@@ -0,0 +1,67 @@
|
||||
from sqlalchemy.exc import OperationalError # type: ignore
|
||||
|
||||
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial captchas table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS captchas (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
memo TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m002_redux(db):
|
||||
"""
|
||||
Creates an improved captchas table and migrates the existing data.
|
||||
"""
|
||||
try:
|
||||
await db.execute("SELECT remembers FROM captchas")
|
||||
|
||||
except OperationalError:
|
||||
await db.execute("ALTER TABLE captchas RENAME TO captchas_old")
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS captchas (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
memo TEXT NOT NULL,
|
||||
description TEXT NULL,
|
||||
amount INTEGER DEFAULT 0,
|
||||
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
remembers INTEGER DEFAULT 0,
|
||||
extras TEXT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON captchas (wallet)")
|
||||
|
||||
for row in [
|
||||
list(row) for row in await db.fetchall("SELECT * FROM captchas_old")
|
||||
]:
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO captchas (
|
||||
id,
|
||||
wallet,
|
||||
url,
|
||||
memo,
|
||||
amount,
|
||||
time
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(row[0], row[1], row[3], row[4], row[5], row[6]),
|
||||
)
|
||||
|
||||
await db.execute("DROP TABLE captchas_old")
|
||||
@@ -0,0 +1,23 @@
|
||||
import json
|
||||
|
||||
from sqlite3 import Row
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
|
||||
class Captcha(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
url: str
|
||||
memo: str
|
||||
description: str
|
||||
amount: int
|
||||
time: int
|
||||
remembers: bool
|
||||
extras: Optional[dict]
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Captcha":
|
||||
data = dict(row)
|
||||
data["remembers"] = bool(data["remembers"])
|
||||
data["extras"] = json.loads(data["extras"]) if data["extras"] else None
|
||||
return cls(**data)
|
||||
@@ -0,0 +1,80 @@
|
||||
var ciframeLoaded = !1,
|
||||
captchaStyleAdded = !1
|
||||
|
||||
function ccreateIframeElement(t = {}) {
|
||||
const e = document.createElement('iframe')
|
||||
// e.style.marginLeft = "25px",
|
||||
;(e.style.border = 'none'),
|
||||
(e.style.width = '100%'),
|
||||
(e.style.height = '100%'),
|
||||
(e.scrolling = 'no'),
|
||||
(e.id = 'captcha-iframe')
|
||||
t.dest, t.amount, t.currency, t.label, t.opReturn
|
||||
var captchaid = document
|
||||
.getElementById('captchascript')
|
||||
.getAttribute('data-captchaid')
|
||||
return (e.src = 'http://localhost:5000/captcha/' + captchaid), e
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (captchaStyleAdded) console.log('Captcha stuff already added!')
|
||||
else {
|
||||
console.log('Adding captcha stuff'), (captchaStyleAdded = !0)
|
||||
var t = document.createElement('style')
|
||||
t.innerHTML =
|
||||
"\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}"
|
||||
var e = document.querySelector('script')
|
||||
e.parentNode.insertBefore(t, e)
|
||||
var i = document.getElementById('captchacheckbox'),
|
||||
n = i.dataset,
|
||||
o = 'true' === n.dark
|
||||
var a = document.createElement('div')
|
||||
;(a.className += ' modal-captcha-container'),
|
||||
(a.innerHTML =
|
||||
'\t\t<div class="modal-captcha-content"> \t<span class="close-button-captcha" style="display: none;">×</span>\t\t</div>\t'),
|
||||
document.getElementsByTagName('body')[0].appendChild(a)
|
||||
var r = document.getElementsByClassName('modal-captcha-content').item(0)
|
||||
document
|
||||
.getElementsByClassName('close-button-captcha')
|
||||
.item(0)
|
||||
.addEventListener('click', d),
|
||||
window.addEventListener('click', function (t) {
|
||||
t.target === a && d()
|
||||
}),
|
||||
i.addEventListener('change', function () {
|
||||
if (this.checked) {
|
||||
// console.log("checkbox checked");
|
||||
if (0 == ciframeLoaded) {
|
||||
// console.log("n: ", n);
|
||||
var t = ccreateIframeElement(n)
|
||||
r.appendChild(t), (ciframeLoaded = !0)
|
||||
}
|
||||
d()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function d() {
|
||||
a.classList.toggle('show-modal-captcha')
|
||||
}
|
||||
})
|
||||
|
||||
function receiveMessage(event) {
|
||||
if (event.data.includes('paymenthash')) {
|
||||
// console.log("paymenthash received: ", event.data);
|
||||
document.getElementById('captchapayhash').value = event.data.split('_')[1]
|
||||
}
|
||||
if (event.data.includes('removetheiframe')) {
|
||||
if (event.data.includes('nok')) {
|
||||
//invoice was NOT paid
|
||||
// console.log("receiveMessage not paid")
|
||||
document.getElementById('captchacheckbox').checked = false
|
||||
}
|
||||
ciframeLoaded = !1
|
||||
var element = document.getElementById('captcha-iframe')
|
||||
document
|
||||
.getElementsByClassName('modal-captcha-container')[0]
|
||||
.classList.toggle('show-modal-captcha')
|
||||
element.parentNode.removeChild(element)
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', receiveMessage, false)
|
||||
@@ -0,0 +1,147 @@
|
||||
<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 captchas">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span> /captcha/api/v1/captchas</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>[<captcha_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root }}captcha/api/v1/captchas -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 captcha">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span> /captcha/api/v1/captchas</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
|
||||
>{"amount": <integer>, "description": <string>, "memo":
|
||||
<string>, "remembers": <boolean>, "url":
|
||||
<string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"amount": <integer>, "description": <string>, "id":
|
||||
<string>, "memo": <string>, "remembers": <boolean>,
|
||||
"time": <int>, "url": <string>, "wallet":
|
||||
<string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root }}captcha/api/v1/captchas -d
|
||||
'{"url": <string>, "memo": <string>, "description":
|
||||
<string>, "amount": <integer>, "remembers":
|
||||
<boolean>}' -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="Create an invoice (public)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/captcha/api/v1/captchas/<captcha_id>/invoice</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"amount": <integer>}</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
|
||||
}}captcha/api/v1/captchas/<captcha_id>/invoice -d '{"amount":
|
||||
<integer>}' -H "Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check invoice status (public)"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">POST</span>
|
||||
/captcha/api/v1/captchas/<captcha_id>/check_invoice</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"payment_hash": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"paid": false}</code><br />
|
||||
<code
|
||||
>{"paid": true, "url": <string>, "remembers":
|
||||
<boolean>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root
|
||||
}}captcha/api/v1/captchas/<captcha_id>/check_invoice -d
|
||||
'{"payment_hash": <string>}' -H "Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a captcha"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/captcha/api/v1/captchas/<captcha_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
|
||||
}}captcha/api/v1/captchas/<captcha_id> -H "X-Api-Key: {{
|
||||
g.user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,178 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-sm-8 col-md-5 col-lg-4">
|
||||
<q-card class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h5 class="text-subtitle1 q-mt-none q-mb-sm">{{ captcha.memo }}</h5>
|
||||
{% if captcha.description %}
|
||||
<p>{{ captcha.description }}</p>
|
||||
{% endif %}
|
||||
<div v-if="!this.redirectUrl" class="q-mt-lg">
|
||||
<q-form v-if="">
|
||||
<q-input
|
||||
filled
|
||||
v-model.number="userAmount"
|
||||
type="number"
|
||||
:min="captchaAmount"
|
||||
suffix="sat"
|
||||
label="Choose an amount *"
|
||||
:hint="'Minimum ' + captchaAmount + ' sat'"
|
||||
>
|
||||
<template v-slot:after>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
icon="check"
|
||||
color="deep-purple"
|
||||
type="submit"
|
||||
@click="createInvoice"
|
||||
:disabled="userAmount < captchaAmount || paymentReq"
|
||||
></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-form>
|
||||
<div v-if="paymentReq" class="q-mt-lg">
|
||||
<a :href="'lightning:' + paymentReq">
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<qrcode
|
||||
:value="paymentReq"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText(paymentReq)"
|
||||
>Copy invoice</q-btn
|
||||
>
|
||||
<q-btn
|
||||
@click="cancelPayment(false)"
|
||||
flat
|
||||
color="grey"
|
||||
class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-separator class="q-my-lg"></q-separator>
|
||||
<p>
|
||||
Captcha accepted. You are probably human.<br />
|
||||
<!-- <strong>{% raw %}{{ redirectUrl }}{% endraw %}</strong> -->
|
||||
</p>
|
||||
<!-- <div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" type="a" :href="redirectUrl"
|
||||
>Open URL</q-btn>
|
||||
</div> -->
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
userAmount: {{ captcha.amount }},
|
||||
captchaAmount: {{ captcha.amount }},
|
||||
paymentReq: null,
|
||||
redirectUrl: null,
|
||||
paymentDialog: {
|
||||
dismissMsg: null,
|
||||
checker: null
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
amount: function () {
|
||||
return (this.captchaAmount > this.userAmount) ? this.captchaAmount : this.userAmount
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
cancelPayment: function (paid) {
|
||||
this.paymentReq = null
|
||||
clearInterval(this.paymentDialog.checker)
|
||||
if (this.paymentDialog.dismissMsg) {
|
||||
this.paymentDialog.dismissMsg()
|
||||
}
|
||||
var removeiframestring = "removetheiframe_nok";
|
||||
var timeout = 500;
|
||||
if(paid){
|
||||
console.log("paid, dismissing iframe");
|
||||
removeiframestring = "removetheiframe_ok";
|
||||
timeout = 2000;
|
||||
}
|
||||
setTimeout(function () {
|
||||
// parent.closeIFrame()
|
||||
parent.window.postMessage(removeiframestring, "*");
|
||||
}, timeout)
|
||||
},
|
||||
createInvoice: function () {
|
||||
var self = this
|
||||
|
||||
axios
|
||||
.post(
|
||||
'/captcha/api/v1/captchas/{{ captcha.id }}/invoice',
|
||||
{amount: this.amount}
|
||||
)
|
||||
.then(function (response) {
|
||||
self.paymentReq = response.data.payment_request.toUpperCase()
|
||||
|
||||
self.paymentDialog.dismissMsg = self.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
|
||||
self.paymentDialog.checker = setInterval(function () {
|
||||
axios
|
||||
.post(
|
||||
'/captcha/api/v1/captchas/{{ captcha.id }}/check_invoice',
|
||||
{payment_hash: response.data.payment_hash}
|
||||
)
|
||||
.then(function (res) {
|
||||
if (res.data.paid) {
|
||||
self.cancelPayment(true)
|
||||
self.redirectUrl = res.data.url
|
||||
if (res.data.remembers) {
|
||||
self.$q.localStorage.set(
|
||||
'lnbits.captcha.{{ captcha.id }}',
|
||||
res.data.url
|
||||
)
|
||||
}
|
||||
|
||||
parent.window.postMessage("paymenthash_"+response.data.payment_hash, "*");
|
||||
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Payment received!',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
var url = this.$q.localStorage.getItem('lnbits.captcha.{{ captcha.id }}')
|
||||
|
||||
if (url) {
|
||||
this.redirectUrl = url
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,425 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
|
||||
>New captcha</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Captchas</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="captchas"
|
||||
row-key="id"
|
||||
:columns="captchasTable.columns"
|
||||
:pagination.sync="captchasTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="launch"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="props.row.displayUrl"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="visibility"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
:href="buildCaptchaSnippet(props.row.id)"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteCaptcha(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">LNbits captcha extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "captcha/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="createCaptcha" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<!-- <q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.url"
|
||||
type="hidden"
|
||||
label="Redirect URL *"
|
||||
:value="https://dummy.com"
|
||||
></q-input> -->
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.memo"
|
||||
label="Title *"
|
||||
placeholder="LNbits captcha"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
autogrow
|
||||
v-model.trim="formDialog.data.description"
|
||||
label="Description"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.amount"
|
||||
type="number"
|
||||
label="Amount (sat) *"
|
||||
hint="This is the minimum amount users can pay/donate."
|
||||
></q-input>
|
||||
<q-list>
|
||||
<q-item tag="label" class="rounded-borders">
|
||||
<q-item-section avatar>
|
||||
<q-checkbox
|
||||
v-model="formDialog.data.remembers"
|
||||
color="deep-purple"
|
||||
></q-checkbox>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Remember payments</q-item-label>
|
||||
<q-item-label caption
|
||||
>A succesful payment will be registered in the browser's
|
||||
storage, so the user doesn't need to pay again to prove they are
|
||||
human.</q-item-label
|
||||
>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.memo == null"
|
||||
type="submit"
|
||||
>Create captcha</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
|
||||
{% raw %}
|
||||
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
|
||||
<!-- <qrcode
|
||||
:value="qrCodeDialog.data.lnurl"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode> -->
|
||||
<code style="word-break: break-all">
|
||||
{{ qrCodeDialog.data.snippet }}
|
||||
</code>
|
||||
<p style="margin-top: 20px">
|
||||
Copy the snippet above and paste into your website/form. The checkbox
|
||||
can be in checked state only after user pays.
|
||||
</p>
|
||||
</q-responsive>
|
||||
<p style="word-break: break-all">
|
||||
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
|
||||
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
|
||||
<!-- <span v-if="qrCodeDialog.data.currency"
|
||||
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
|
||||
fiatRates[qrCodeDialog.data.currency] ?
|
||||
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
|
||||
/></span>
|
||||
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
|
||||
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
|
||||
}}<br />
|
||||
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br /> -->
|
||||
</p>
|
||||
{% endraw %}
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.snippet, 'Snippet copied to clipboard!')"
|
||||
class="q-ml-sm"
|
||||
>Copy Snippet</q-btn
|
||||
>
|
||||
<!-- <q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
|
||||
>Shareable link</q-btn
|
||||
>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
icon="print"
|
||||
type="a"
|
||||
:href="qrCodeDialog.data.print_url"
|
||||
target="_blank"
|
||||
></q-btn> -->
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapCaptcha = function (obj) {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.displayUrl = ['/captcha/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
captchas: [],
|
||||
captchasTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'memo', align: 'left', label: 'Memo', field: 'memo'},
|
||||
{
|
||||
name: 'amount',
|
||||
align: 'right',
|
||||
label: 'Amount (sat)',
|
||||
field: 'fsat',
|
||||
sortable: true,
|
||||
sort: function (a, b, rowA, rowB) {
|
||||
return rowA.amount - rowB.amount
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'remembers',
|
||||
align: 'left',
|
||||
label: 'Remember',
|
||||
field: 'remembers'
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
align: 'left',
|
||||
label: 'Date',
|
||||
field: 'date',
|
||||
sortable: true
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
remembers: false
|
||||
}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCaptchas: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/captcha/api/v1/captchas?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.captchas = response.data.map(function (obj) {
|
||||
return mapCaptcha(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
createCaptcha: function () {
|
||||
var data = {
|
||||
// url: this.formDialog.data.url,
|
||||
url: 'http://dummy.com',
|
||||
memo: this.formDialog.data.memo,
|
||||
amount: this.formDialog.data.amount,
|
||||
description: this.formDialog.data.description,
|
||||
remembers: this.formDialog.data.remembers
|
||||
}
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/captcha/api/v1/captchas',
|
||||
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.captchas.push(mapCaptcha(response.data))
|
||||
self.formDialog.show = false
|
||||
self.formDialog.data = {
|
||||
remembers: false
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteCaptcha: function (captchaId) {
|
||||
var self = this
|
||||
var captcha = _.findWhere(this.captchas, {id: captchaId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this captcha link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/captcha/api/v1/captchas/' + captchaId,
|
||||
_.findWhere(self.g.user.wallets, {id: captcha.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.captchas = _.reject(self.captchas, function (obj) {
|
||||
return obj.id == captchaId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
buildCaptchaSnippet: function (captchaId) {
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var captchasnippet =
|
||||
'<!-- Captcha Checkbox Start -->\n' +
|
||||
'<input type="checkbox" id="captchacheckbox">\n' +
|
||||
'<label for="captchacheckbox">I\'m not a robot</label><br/>\n' +
|
||||
'<input type="text" id="captchapayhash" style="display: none;"/>\n' +
|
||||
'<script type="text/javascript" src="' +
|
||||
locationPath +
|
||||
'static/js/captcha.js" id="captchascript" data-captchaid="' +
|
||||
captchaId +
|
||||
'">\n' +
|
||||
'<\/script>\n' +
|
||||
'<!-- Captcha Checkbox End -->'
|
||||
return captchasnippet
|
||||
},
|
||||
openQrCodeDialog(captchaId) {
|
||||
// var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
var captcha = _.findWhere(this.captchas, {id: captchaId})
|
||||
// if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
||||
this.qrCodeDialog.data = {
|
||||
id: captcha.id,
|
||||
amount: captcha.amount,
|
||||
// (link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
|
||||
// ' ' +
|
||||
// (link.currency || 'sat'),
|
||||
snippet: this.buildCaptchaSnippet(captcha.id)
|
||||
// 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
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.captchasTable.columns, this.captchas)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getCaptchas()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
from quart import g, abort, render_template
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
|
||||
from . import captcha_ext
|
||||
from .crud import get_captcha
|
||||
|
||||
|
||||
@captcha_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("captcha/index.html", user=g.user)
|
||||
|
||||
|
||||
@captcha_ext.route("/<captcha_id>")
|
||||
async def display(captcha_id):
|
||||
captcha = await get_captcha(captcha_id) or abort(
|
||||
HTTPStatus.NOT_FOUND, "captcha does not exist."
|
||||
)
|
||||
return await render_template("captcha/display.html", captcha=captcha)
|
||||
@@ -0,0 +1,121 @@
|
||||
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, check_invoice_status
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from . import captcha_ext
|
||||
from .crud import create_captcha, get_captcha, get_captchas, delete_captcha
|
||||
|
||||
|
||||
@captcha_ext.route("/api/v1/captchas", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_captchas():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
|
||||
|
||||
return (
|
||||
jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@captcha_ext.route("/api/v1/captchas", methods=["POST"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"url": {"type": "string", "empty": False, "required": True},
|
||||
"memo": {"type": "string", "empty": False, "required": True},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"empty": True,
|
||||
"nullable": True,
|
||||
"required": False,
|
||||
},
|
||||
"amount": {"type": "integer", "min": 0, "required": True},
|
||||
"remembers": {"type": "boolean", "required": True},
|
||||
}
|
||||
)
|
||||
async def api_captcha_create():
|
||||
captcha = await create_captcha(wallet_id=g.wallet.id, **g.data)
|
||||
return jsonify(captcha._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@captcha_ext.route("/api/v1/captchas/<captcha_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_captcha_delete(captcha_id):
|
||||
captcha = await get_captcha(captcha_id)
|
||||
|
||||
if not captcha:
|
||||
return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if captcha.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your captcha."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
await delete_captcha(captcha_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@captcha_ext.route("/api/v1/captchas/<captcha_id>/invoice", methods=["POST"])
|
||||
@api_validate_post_request(
|
||||
schema={"amount": {"type": "integer", "min": 1, "required": True}}
|
||||
)
|
||||
async def api_captcha_create_invoice(captcha_id):
|
||||
captcha = await get_captcha(captcha_id)
|
||||
|
||||
if g.data["amount"] < captcha.amount:
|
||||
return (
|
||||
jsonify({"message": f"Minimum amount is {captcha.amount} sat."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
amount = (
|
||||
g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount
|
||||
)
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=captcha.wallet,
|
||||
amount=amount,
|
||||
memo=f"{captcha.memo}",
|
||||
extra={"tag": "captcha"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
return (
|
||||
jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@captcha_ext.route("/api/v1/captchas/<captcha_id>/check_invoice", methods=["POST"])
|
||||
@api_validate_post_request(
|
||||
schema={"payment_hash": {"type": "string", "empty": False, "required": True}}
|
||||
)
|
||||
async def api_paywal_check_invoice(captcha_id):
|
||||
captcha = await get_captcha(captcha_id)
|
||||
|
||||
if not captcha:
|
||||
return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
try:
|
||||
status = await check_invoice_status(captcha.wallet, g.data["payment_hash"])
|
||||
is_paid = not status.pending
|
||||
except Exception:
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
if is_paid:
|
||||
wallet = await get_wallet(captcha.wallet)
|
||||
payment = await wallet.get_payment(g.data["payment_hash"])
|
||||
await payment.set_pending(False)
|
||||
|
||||
return (
|
||||
jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
@@ -1,7 +1,9 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates")
|
||||
diagonalley_ext: Blueprint = Blueprint(
|
||||
"diagonalley", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
"name": "Diagon Alley",
|
||||
"short_description": "Movable anonymous market stand",
|
||||
"icon": "add_shopping_cart",
|
||||
"contributors": ["eillarra"]
|
||||
"contributors": ["benarc"]
|
||||
}
|
||||
@@ -1,30 +1,52 @@
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(product_id, wallet_id, product, categories, description, image, price, quantity),
|
||||
(
|
||||
product_id,
|
||||
wallet_id,
|
||||
product,
|
||||
categories,
|
||||
description,
|
||||
image,
|
||||
price,
|
||||
quantity,
|
||||
),
|
||||
)
|
||||
|
||||
return get_diagonalleys_product(product_id)
|
||||
@@ -34,7 +56,9 @@ def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers]
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id))
|
||||
db.execute(
|
||||
f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id)
|
||||
)
|
||||
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
|
||||
|
||||
return get_diagonalleys_indexer(product_id)
|
||||
@@ -53,7 +77,9 @@ def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Product
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Products(**row) for row in rows]
|
||||
|
||||
@@ -63,19 +89,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)
|
||||
|
||||
@@ -84,7 +130,9 @@ def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers]
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id))
|
||||
db.execute(
|
||||
f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id)
|
||||
)
|
||||
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
||||
|
||||
return get_diagonalleys_indexer(indexer_id)
|
||||
@@ -94,17 +142,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
|
||||
@@ -116,22 +176,38 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
|
||||
|
||||
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,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
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,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
return [Indexers(**row) for row in rows]
|
||||
|
||||
|
||||
@@ -140,20 +216,44 @@ 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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(order_id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, False, False),
|
||||
(
|
||||
order_id,
|
||||
productid,
|
||||
wallet,
|
||||
product,
|
||||
quantity,
|
||||
shippingzone,
|
||||
address,
|
||||
email,
|
||||
invoiceid,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
return get_diagonalleys_order(order_id)
|
||||
|
||||
|
||||
@@ -170,13 +270,23 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
|
||||
with open_ext_db("diagonalley") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
rows = db.fetchall(
|
||||
f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
for r in rows:
|
||||
PAID = WALLET.get_invoice_status(r["invoiceid"]).paid
|
||||
PAID = (await 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"],))
|
||||
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
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):
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial products table.
|
||||
"""
|
||||
db.execute("""
|
||||
await 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("""
|
||||
await 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("""
|
||||
await 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
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">Diagon Alley: Movable, anonymous market stall</h5>
|
||||
<p>Make a list of products to sell, point your list of products at a public indexer. Buyers browse your products on the indexer, and pay you directly. Ratings are managed by the indexer. Your stall can be listed in multiple indexers, even over TOR, if you wish to be anonymous.<br/>
|
||||
<small> Created by, <a href="https://github.com/benarc">Ben Arc</a></small></p>
|
||||
</q-card>
|
||||
</q-card-section>
|
||||
|
||||
</q-card-section>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="Info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h5 class="text-subtitle1 q-my-none">
|
||||
Diagon Alley: Decentralised Market-Stalls
|
||||
</h5>
|
||||
<p>
|
||||
Make a list of products to sell, point your list of products at a public
|
||||
indexer. Buyers browse your products on the indexer, and pay you
|
||||
directly. Ratings are managed by the indexer. Your stall can be listed
|
||||
in multiple indexers, even over TOR, if you wish to be anonymous.<br />
|
||||
More information on the
|
||||
<a href="https://github.com/lnbits/Diagon-Alley"
|
||||
>Diagon Alley Protocol</a
|
||||
><br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a></small
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
@@ -21,43 +31,91 @@
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
|
||||
<q-expansion-item group="api" dense expand-separator label="Get prodcuts, categorised by wallet">
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get prodcuts, categorised by wallet"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-blue">GET</span> /api/v1/diagonalley/stall/products/<indexer_id></code>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/api/v1/diagonalley/stall/products/<indexer_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>Product JSON list</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code>curl -X GET {{ request.url_root }}diagonalley/api/v1/diagonalley/stall/products/<indexer_id></code>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}diagonalley/api/v1/diagonalley/stall/products/<indexer_id></code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get invoice for product">
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Get invoice for product"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-green">POST</span> /api/v1/diagonalley/stall/order/<indexer_id></code>
|
||||
<code
|
||||
><span class="text-light-green">POST</span>
|
||||
/api/v1/diagonalley/stall/order/<indexer_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"id": <string>, "address": <string>, "shippingzone": <integer>, "email": <string>, "quantity": <integer>}</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
|
||||
>{"id": <string>, "address": <string>, "shippingzone":
|
||||
<integer>, "email": <string>, "quantity":
|
||||
<integer>}</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
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code>curl -X POST {{ request.url_root }}diagonalley/api/v1/diagonalley/stall/order/<indexer_id> -d '{"id": <product_id&>, "email": <customer_email>, "address": <customer_address>, "quantity": 2, "shippingzone": 1}' -H "Content-type: application/json"
|
||||
</code>
|
||||
<code
|
||||
>curl -X POST {{ request.url_root
|
||||
}}diagonalley/api/v1/diagonalley/stall/order/<indexer_id> -d
|
||||
'{"id": <product_id&>, "email": <customer_email>,
|
||||
"address": <customer_address>, "quantity": 2, "shippingzone":
|
||||
1}' -H "Content-type: application/json"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Check a product has been shipped"
|
||||
class="q-mb-md">
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Check a product has been shipped"
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-light-blue">GET</span> /diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id></code>
|
||||
<code
|
||||
><span class="text-light-blue">GET</span>
|
||||
/diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<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>{"shipped": <boolean>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code>curl -X GET {{ request.url_root }}diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id> -H "Content-type: application/json"</code>
|
||||
<code
|
||||
>curl -X GET {{ request.url_root
|
||||
}}diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id>
|
||||
-H "Content-type: application/json"</code
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +1,3 @@
|
||||
<script>console.log("{{ stall }}")</script>
|
||||
<script>
|
||||
console.log('{{ stall }}')
|
||||
</script>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
from flask import g, abort, render_template, jsonify
|
||||
import json
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.extensions.diagonalley import diagonalley_ext
|
||||
from lnbits.helpers import Status
|
||||
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,232 +1,317 @@
|
||||
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.helpers import Status
|
||||
|
||||
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:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return jsonify([product._asdict() for product in get_diagonalleys_products(wallet_ids)]), Status.OK
|
||||
return (
|
||||
jsonify(
|
||||
[product._asdict() for product in get_diagonalleys_products(wallet_ids)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
if not product:
|
||||
return jsonify({"message": "Withdraw product does not exist."}), Status.NOT_FOUND
|
||||
return (
|
||||
jsonify({"message": "Withdraw product does not exist."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if product.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your withdraw product."}), Status.FORBIDDEN
|
||||
return (
|
||||
jsonify({"message": "Not your withdraw product."}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
product = update_diagonalleys_product(product_id, **g.data)
|
||||
else:
|
||||
product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data)
|
||||
|
||||
return jsonify(product._asdict()), Status.OK if product_id else Status.CREATED
|
||||
|
||||
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:
|
||||
return jsonify({"message": "Product does not exist."}), Status.NOT_FOUND
|
||||
return jsonify({"message": "Product does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if product.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your Diagon Alley."}), Status.FORBIDDEN
|
||||
return jsonify({"message": "Not your Diagon Alley."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_diagonalleys_product(product_id)
|
||||
|
||||
return "", Status.NO_CONTENT
|
||||
|
||||
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:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return jsonify([indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]), Status.OK
|
||||
return (
|
||||
jsonify(
|
||||
[indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
if not indexer:
|
||||
return jsonify({"message": "Withdraw indexer does not exist."}), Status.NOT_FOUND
|
||||
return (
|
||||
jsonify({"message": "Withdraw indexer does not exist."}),
|
||||
HTTPStatus.NOT_FOUND,
|
||||
)
|
||||
|
||||
if indexer.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your withdraw indexer."}), Status.FORBIDDEN
|
||||
return (
|
||||
jsonify({"message": "Not your withdraw indexer."}),
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
|
||||
indexer = update_diagonalleys_indexer(indexer_id, **g.data)
|
||||
else:
|
||||
indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data)
|
||||
|
||||
return jsonify(indexer._asdict()), Status.OK if indexer_id else Status.CREATED
|
||||
return (
|
||||
jsonify(indexer._asdict()),
|
||||
HTTPStatus.OK if indexer_id else HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
return jsonify({"message": "Indexer does not exist."}), Status.NOT_FOUND
|
||||
return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if indexer.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your Indexer."}), Status.FORBIDDEN
|
||||
return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_diagonalleys_indexer(indexer_id)
|
||||
|
||||
return "", Status.NO_CONTENT
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
###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:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]), Status.OK
|
||||
return (
|
||||
jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@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()), Status.CREATED
|
||||
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:
|
||||
return jsonify({"message": "Indexer does not exist."}), Status.NOT_FOUND
|
||||
return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if order.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your Indexer."}), Status.FORBIDDEN
|
||||
return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_diagonalleys_indexer(order_id)
|
||||
|
||||
return "", Status.NO_CONTENT
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@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,))
|
||||
return "", Status.OK
|
||||
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"])]), Status.OK
|
||||
return (
|
||||
jsonify(
|
||||
[order._asdict() for order in get_diagonalleys_orders(order["wallet"])]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
###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):
|
||||
|
||||
@diagonalley_ext.route(
|
||||
"/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"]
|
||||
)
|
||||
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])
|
||||
if not rows:
|
||||
return jsonify({"message": "Indexer does not exist."}), Status.NOT_FOUND
|
||||
return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
products = db.fetchone("SELECT * FROM products WHERE wallet = ?", (rows[1],))
|
||||
if not products:
|
||||
return jsonify({"message": "No products"}), Status.NOT_FOUND
|
||||
return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND
|
||||
|
||||
return (
|
||||
jsonify(
|
||||
[products._asdict() for products in get_diagonalleys_products(rows[1])]
|
||||
),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), Status.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):
|
||||
|
||||
@diagonalley_ext.route(
|
||||
"/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"]
|
||||
)
|
||||
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"]}), Status.OK
|
||||
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,17 +320,33 @@ 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}), Status.OK
|
||||
|
||||
|
||||
|
||||
return (
|
||||
jsonify({"checking_id": checking_id, "payment_request": payment_request}),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
<h1> LNEVENTS</h1>
|
||||
<h2>Create/sell tickets for an event</h2>
|
||||
<p>Events is an easy way to create/sell tickets for an event.
|
||||
|
||||
It is advised to setup a specific wallet in lnbits for the event.</p>
|
||||
|
||||
<img src="https://i.imgur.com/qHi7ExL.png">
|
||||
|
||||
<h1>Events</h1>
|
||||
<h2>Events: Sell and register event tickets</h2>
|
||||
Events alows you to make a wave of tickets for an event, each ticket is in the form of a unqiue QRcode, which the user presents at registration. Events comes with a shareable ticket scanner, which can be used to register attendees.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
from lnbits.db import Database
|
||||
|
||||
db = Database("ext_events")
|
||||
|
||||
|
||||
events_ext: Blueprint = Blueprint("events", __name__, static_folder="static", template_folder="templates")
|
||||
events_ext: Blueprint = Blueprint(
|
||||
"events", __name__, static_folder="static", template_folder="templates"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Events",
|
||||
"short_description": "Sell and register event tickets",
|
||||
"icon": "local_activity",
|
||||
"contributors": ["benarc"]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Events",
|
||||
"short_description": "LN tickets for events.",
|
||||
"icon": "local_activity",
|
||||
"contributors": ["arcbtc"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user