Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
0197c6c44a | ||
|
|
c3e337a319 | ||
|
|
976a3d4e5c | ||
|
|
4b4a297c3f | ||
|
|
5d128523c7 | ||
|
|
b34a0a9835 | ||
|
|
99cfdd5531 | ||
|
|
435f9c9773 | ||
|
|
ab190454f4 | ||
|
|
5fe64d324d | ||
|
|
19fa6c2bf9 | ||
|
|
efd2cb7170 | ||
|
|
4995ab5aaf | ||
|
|
7acbe9f93c | ||
|
|
122281ba81 | ||
|
|
97f4fabea1 | ||
|
|
2c30209cba | ||
|
|
9f22c0395e | ||
|
|
2cf5b13b93 | ||
|
|
bedf01b1b6 | ||
|
|
eff41ddf8d | ||
|
|
19e439c9a2 | ||
|
|
fb86aad6a9 | ||
|
|
e226df2894 | ||
|
|
d15ba01f94 | ||
|
|
897644956a | ||
|
|
f02526585f | ||
|
|
4bf93d36c6 | ||
|
|
e461e34fc0 | ||
|
|
34e6d2a7bb | ||
|
|
1978cafe44 | ||
|
|
cd22aaded9 | ||
|
|
498eceeb46 | ||
|
|
39041ffb5a | ||
|
|
0c88aa8fcf | ||
|
|
eb938c11a8 | ||
|
|
b1da103080 | ||
|
|
d90ae26062 | ||
|
|
403385c205 | ||
|
|
fd4dc6c48f | ||
|
|
e1c5e970c4 | ||
|
|
f12f52db2d | ||
|
|
8d328c5465 | ||
|
|
efb9deb028 | ||
|
|
5806beaeb7 | ||
|
|
d917feeb02 | ||
|
|
efbba20872 | ||
|
|
93e51a7398 | ||
|
|
94e524bf4c | ||
|
|
364b6dc857 | ||
|
|
bb2d1ea140 | ||
|
|
409122271a | ||
|
|
07b4d52432 | ||
|
|
ae778c19a1 | ||
|
|
65c17488e3 | ||
|
|
98f7dc8eff | ||
|
|
6bdf4e22e4 | ||
|
|
549976b9d0 | ||
|
|
f0cbaea0b1 | ||
|
|
ca08dc2302 | ||
|
|
7a1477c056 | ||
|
|
27b02e85db | ||
|
|
d82e06a4f9 | ||
|
|
39098b2432 | ||
|
|
5aab51b78c | ||
|
|
5f9c9c9df6 | ||
|
|
40fc7ad08f | ||
|
|
51587d7de2 | ||
|
|
1154d632a2 | ||
|
|
1862cccb2a | ||
|
|
c33a45d20e | ||
|
|
0178097ee6 | ||
|
|
57c26ed2bb | ||
|
|
9e98dae317 | ||
|
|
aa13551bd3 | ||
|
|
b52618f6f5 | ||
|
|
5c34c23617 | ||
|
|
5b16bd0224 | ||
|
|
316b2256e4 | ||
|
|
10b8f90fcc | ||
|
|
b38e6bf566 | ||
|
|
d4c9043278 | ||
|
|
d03e472cc2 | ||
|
|
de75057a15 | ||
|
|
a455379ee1 | ||
|
|
2d694c6b8a | ||
|
|
3dadf5b54f | ||
|
|
e330771b44 | ||
|
|
03a365256f | ||
|
|
83bb184ff3 | ||
|
|
49c1537b25 | ||
|
|
c9ff6c9a4c | ||
|
|
68fbe2dabf | ||
|
|
d5f4697251 | ||
|
|
fd4dddda6e | ||
|
|
a834a64319 | ||
|
|
dd23b20090 | ||
|
|
490e166f75 | ||
|
|
6651122bb6 | ||
|
|
764fde012c | ||
|
|
abc694307a | ||
|
|
016d07bc4d | ||
|
|
2f9542bee6 | ||
|
|
a637a7f7e7 | ||
|
|
aff2ab1780 | ||
|
|
609f5e8470 | ||
|
|
ba9449db23 | ||
|
|
1b82df8094 | ||
|
|
708b2f2652 | ||
|
|
7a6c87bd8e | ||
|
|
a468904356 | ||
|
|
11c8e46b26 | ||
|
|
af5269e239 | ||
|
|
4397a6cab3 | ||
|
|
949f816a78 | ||
|
|
f40ead9701 | ||
|
|
ce46e8eb31 | ||
|
|
77d8851009 | ||
|
|
d282e6dea6 | ||
|
|
211b72648b | ||
|
|
e65c0a47bb | ||
|
|
768f0427e7 | ||
|
|
fb7dfb3a32 | ||
|
|
089793a486 | ||
|
|
c866d0572e | ||
|
|
7b962ffc4b | ||
|
|
8dc2143080 | ||
|
|
a81de95f73 | ||
|
|
d0b44eada9 | ||
|
|
434029966f | ||
|
|
d6cab311c5 | ||
|
|
676bd24d38 | ||
|
|
5fb17a9244 | ||
|
|
de187559a8 | ||
|
|
7fced84f57 | ||
|
|
1e6c6a8c3c | ||
|
|
426981fe1c | ||
|
|
46c11f3946 | ||
|
|
8fe9864e43 | ||
|
|
8db35ca1af | ||
|
|
77e849f056 | ||
|
|
377b0c9d47 | ||
|
|
0ca5586465 | ||
|
|
1248f85612 | ||
|
|
e57bebd01b | ||
|
|
908d1cbf24 | ||
|
|
97719623a3 | ||
|
|
ccb9b462cc | ||
|
|
127f1ee258 | ||
|
|
1db380d88e | ||
|
|
9ead85987f | ||
|
|
4562d23f6e | ||
|
|
f6f2304c5a | ||
|
|
d194ba5fe1 | ||
|
|
c2a116b713 | ||
|
|
c6f47fc589 | ||
|
|
4533363c49 | ||
|
|
efc847d381 | ||
|
|
b02bee01e5 | ||
|
|
f364ce51c0 | ||
|
|
73ec8deee1 | ||
|
|
649cc888ab | ||
|
|
d03785558b | ||
|
|
75d97ddfc1 | ||
|
|
49addb8d56 | ||
|
|
d862b16ee6 | ||
|
|
6e26e06aea | ||
|
|
7996c48b70 | ||
|
|
9382381144 | ||
|
|
f98a5040ac |
@@ -5,8 +5,6 @@ charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{html,js,json}]
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
FLASK_APP=lnbits
|
||||
FLASK_ENV=development
|
||||
QUART_APP=lnbits.app
|
||||
QUART_ENV=development
|
||||
QUART_DEBUG=true
|
||||
|
||||
LND_API_ENDPOINT=https://mylnd.io/rest/
|
||||
LND_ADMIN_MACAROON=LND_ADMIN_MACAROON
|
||||
LND_INVOICE_MACAROON=LND_INVOICE_MACAROON
|
||||
LND_READ_MACAROON=LND_READ_MACAROON
|
||||
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" #IMPORTANT! i.e. "/home/satoshi/lnbits/lnbits/data"
|
||||
LNBITS_DISABLED_EXTENSIONS="amilk"
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
LNBITS_SERVICE_FEE="0.0"
|
||||
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC), LndRestWallet, CLightningWallet, LnbitsWallet
|
||||
LNBITS_BACKEND_WALLET_CLASS=LntxbotWallet
|
||||
|
||||
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
|
||||
|
||||
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_REST_ENDPOINT=https://localhost: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"
|
||||
|
||||
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
|
||||
|
||||
LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/
|
||||
LNTXBOT_ADMIN_KEY=LNTXBOT_ADMIN_KEY
|
||||
@@ -13,11 +48,3 @@ LNTXBOT_INVOICE_KEY=LNTXBOT_INVOICE_KEY
|
||||
OPENNODE_API_ENDPOINT=https://api.opennode.com/
|
||||
OPENNODE_ADMIN_KEY=OPENNODE_ADMIN_KEY
|
||||
OPENNODE_INVOICE_KEY=OPENNODE_INVOICE_KEY
|
||||
|
||||
LNPAY_API_ENDPOINT=https://lnpay.co/v1/
|
||||
LNPAY_ADMIN_KEY=LNPAY_ADMIN_KEY
|
||||
LNPAY_INVOICE_KEY=LNPAY_INVOICE_KEY
|
||||
LNPAY_READ_KEY=LNPAY_READ_KEY
|
||||
LNPAY_API_KEY=LNPAY_API_KEY
|
||||
|
||||
FEE_RESERVE=0
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
name: mypy
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: jpetrucciani/mypy-check@master
|
||||
with:
|
||||
path: lnbits
|
||||
@@ -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
|
||||
@@ -11,8 +11,9 @@ __pycache__
|
||||
*.egg-info
|
||||
.coverage
|
||||
.pytest_cache
|
||||
.webassets-cache
|
||||
htmlcov
|
||||
Pipfile.lock
|
||||
test-reports
|
||||
|
||||
*.swo
|
||||
*.swp
|
||||
@@ -20,7 +21,13 @@ Pipfile.lock
|
||||
*.pyc
|
||||
*.env
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
|
||||
*.sqlite3
|
||||
.pyre*
|
||||
|
||||
__bundle__
|
||||
|
||||
node_modules
|
||||
lnbits/static/bundle.*
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.7-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -q -r requirements.txt
|
||||
RUN pip install --no-cache-dir -q hypercorn
|
||||
COPY . /app
|
||||
|
||||
EXPOSE 5000
|
||||
@@ -0,0 +1,23 @@
|
||||
all: format check requirements.txt
|
||||
|
||||
format: prettier black
|
||||
|
||||
check: mypy checkprettier checkblack
|
||||
|
||||
prettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
./node_modules/.bin/prettier --write lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
||||
|
||||
black: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/black lnbits
|
||||
|
||||
mypy: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/mypy lnbits
|
||||
|
||||
checkprettier: $(shell find lnbits -name "*.js" -name ".html")
|
||||
./node_modules/.bin/prettier --check lnbits/static/js/*.js lnbits/core/static/js/*.js lnbits/extensions/*/templates/*/*.html ./lnbits/core/templates/core/*.html lnbits/templates/*.html lnbits/extensions/*/static/js/*.js
|
||||
|
||||
checkblack: $(shell find lnbits -name "*.py")
|
||||
./venv/bin/black --check lnbits
|
||||
|
||||
requirements.txt: Pipfile.lock
|
||||
cat Pipfile.lock | jq -r '.default | map_values(.version) | to_entries | map("\(.key)\(.value)") | join("\n")' > requirements.txt
|
||||
@@ -8,15 +8,22 @@ python_version = "3.7"
|
||||
|
||||
[packages]
|
||||
bitstring = "*"
|
||||
cerberus = "*"
|
||||
ecdsa = "*"
|
||||
environs = "*"
|
||||
lnurl = "*"
|
||||
flask = "*"
|
||||
flask-talisman = "*"
|
||||
pyscss = "*"
|
||||
requests = "*"
|
||||
shortuuid = "*"
|
||||
quart = "*"
|
||||
quart-cors = "*"
|
||||
quart-compress = "*"
|
||||
secure = "*"
|
||||
typing-extensions = "*"
|
||||
|
||||
[dev-packages]
|
||||
black = "==19.10b0"
|
||||
flake8 = "*"
|
||||
flake8-mypy = "*"
|
||||
black = "==20.8b1"
|
||||
pytest = "*"
|
||||
pytest-cov = "*"
|
||||
pytest-sugar = "*"
|
||||
pytest-asyncio = "*"
|
||||
mypy = "==0.761"
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "775e2ea809508c83df4696bba3a38a03e288cba22f0a7f562120230f40351ab9"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.7"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"aiofiles": {
|
||||
"hashes": [
|
||||
"sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb",
|
||||
"sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af"
|
||||
],
|
||||
"version": "==0.5.0"
|
||||
},
|
||||
"bech32": {
|
||||
"hashes": [
|
||||
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
|
||||
"sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.2.0"
|
||||
},
|
||||
"bitstring": {
|
||||
"hashes": [
|
||||
"sha256:fdf3eb72b229d2864fb507f8f42b1b2c57af7ce5fec035972f9566de440a864a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.1.7"
|
||||
},
|
||||
"blinker": {
|
||||
"hashes": [
|
||||
"sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"
|
||||
],
|
||||
"version": "==1.4"
|
||||
},
|
||||
"brotli": {
|
||||
"hashes": [
|
||||
"sha256:160c78292e98d21e73a4cc7f76a234390e516afcd982fa17e1422f7c6a9ce9c8",
|
||||
"sha256:1c48472a6ba3b113452355b9af0a60da5c2ae60477f8feda8346f8fd48e3e87c",
|
||||
"sha256:268fe94547ba25b58ebc724680609c8ee3e5a843202e9a381f6f9c5e8bdb5c70",
|
||||
"sha256:269a5743a393c65db46a7bb982644c67ecba4b8d91b392403ad8a861ba6f495f",
|
||||
"sha256:35a3edbe18e876e596553c4007a087f8bcfd538f19bc116917b3c7522fca0429",
|
||||
"sha256:3b78a24b5fd13c03ee2b7b86290ed20efdc95da75a3557cc06811764d5ad1126",
|
||||
"sha256:40d15c79f42e0a2c72892bf407979febd9cf91f36f495ffb333d1d04cebb34e4",
|
||||
"sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438",
|
||||
"sha256:503fa6af7da9f4b5780bb7e4cbe0c639b010f12be85d02c99452825dd0feef3f",
|
||||
"sha256:5913a1177fc36e30fcf6dc868ce23b0453952c78c04c266d3149b3d39e1410d6",
|
||||
"sha256:5b6ef7d9f9c38292df3690fe3e302b5b530999fa90014853dcd0d6902fb59f26",
|
||||
"sha256:61a7ee1f13ab913897dac7da44a73c6d44d48a4adff42a5701e3239791c96e14",
|
||||
"sha256:68715970f16b6e92c574c30747c95cf8cf62804569647386ff032195dc89a430",
|
||||
"sha256:6b2ae9f5f67f89aade1fab0f7fd8f2832501311c363a21579d02defa844d9296",
|
||||
"sha256:6c772d6c0a79ac0f414a9f8947cc407e119b8598de7621f39cacadae3cf57d12",
|
||||
"sha256:7cb81373984cc0e4682f31bc3d6be9026006d96eecd07ea49aafb06897746452",
|
||||
"sha256:88c63a1b55f352b02c6ffd24b15ead9fc0e8bf781dbe070213039324922a2eea",
|
||||
"sha256:93130612b837103e15ac3f9cbacb4613f9e348b58b3aad53721d92e57f96d46a",
|
||||
"sha256:97f715cf371b16ac88b8c19da00029804e20e25f30d80203417255d239f228b5",
|
||||
"sha256:9d12cf2851759b8de8ca5fde36a59c08210a97ffca0eb94c532ce7b17c6a3d1d",
|
||||
"sha256:afde17ae04d90fbe53afb628f7f2d4ca022797aa093e809de5c3cf276f61bbfa",
|
||||
"sha256:b663f1e02de5d0573610756398e44c130add0eb9a3fc912a09665332942a2efb",
|
||||
"sha256:c2415d9d082152460f2bd4e382a1e85aed233abc92db5a3880da2257dc7daf7b",
|
||||
"sha256:c83aa123d56f2e060644427a882a36b3c12db93727ad7a7b9efd7d7f3e9cc2c4",
|
||||
"sha256:db844eb158a87ccab83e868a762ea8024ae27337fc7ddcbfcddd157f841fdfe7",
|
||||
"sha256:defed7ea5f218a9f2336301e6fd379f55c655bea65ba2476346340a0ce6f74a1",
|
||||
"sha256:f909bbbc433048b499cb9db9e713b5d8d949e8c109a2a548502fb9aa8630f0b1"
|
||||
],
|
||||
"version": "==1.0.9"
|
||||
},
|
||||
"cerberus": {
|
||||
"hashes": [
|
||||
"sha256:302e6694f206dd85cb63f13fd5025b31ab6d38c99c50c6d769f8fa0b0f299589"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.2"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
|
||||
"sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
|
||||
],
|
||||
"version": "==2020.6.20"
|
||||
},
|
||||
"chardet": {
|
||||
"hashes": [
|
||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
||||
],
|
||||
"version": "==3.0.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==7.1.2"
|
||||
},
|
||||
"ecdsa": {
|
||||
"hashes": [
|
||||
"sha256:494c6a853e9ed2e9be33d160b41d47afc50a6629b993d2b9c5ad7bb226add892",
|
||||
"sha256:ca359c971594dceebf334f3d623dae43163ab161c7d09f28cae70a86df26eb7a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"environs": {
|
||||
"hashes": [
|
||||
"sha256:a98005aab7613b6fe7a1af7192a5163f72a52d3348d3918e6c7a2a32e4012779",
|
||||
"sha256:bf3fd6bc54fcfd7f512ddcb80a7781f0ced2b0c83dd123d619e9468ecdaaf537"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==8.0.0"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:311dc5478c2568cc07262e0381cdfc5b9c6ba19775905736c87e81ae6662b9fd",
|
||||
"sha256:9eecfbafc980976dbff26a01dd3487644dd5d00f8038584451fc64a660f7c502"
|
||||
],
|
||||
"version": "==0.10.0"
|
||||
},
|
||||
"h2": {
|
||||
"hashes": [
|
||||
"sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5",
|
||||
"sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"
|
||||
],
|
||||
"version": "==3.2.0"
|
||||
},
|
||||
"hpack": {
|
||||
"hashes": [
|
||||
"sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89",
|
||||
"sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"
|
||||
],
|
||||
"version": "==3.0.0"
|
||||
},
|
||||
"hypercorn": {
|
||||
"hashes": [
|
||||
"sha256:19f32e7267225c8108ad585b2c5deddf1fe75950797a0e87a682a3a00ef1af95",
|
||||
"sha256:809d77f3bf9fa0794a598d8dfa0f8d889e7e1c2f927581cd33068803169dc474"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==0.10.2"
|
||||
},
|
||||
"hyperframe": {
|
||||
"hashes": [
|
||||
"sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40",
|
||||
"sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"
|
||||
],
|
||||
"version": "==5.2.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
|
||||
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.10"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
|
||||
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
|
||||
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==2.11.2"
|
||||
},
|
||||
"lnurl": {
|
||||
"hashes": [
|
||||
"sha256:aaff8552cd4c02f8ae30c82dd3ebe6c6c3a8e06cbc59991281534ba0bbe12890"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.3.5"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
||||
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
||||
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
|
||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
||||
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
||||
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
||||
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
||||
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
||||
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
||||
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
||||
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
|
||||
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
|
||||
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"marshmallow": {
|
||||
"hashes": [
|
||||
"sha256:2272273505f1644580fbc66c6b220cc78f893eb31f1ecde2af98ad28011e9811",
|
||||
"sha256:47911dd7c641a27160f0df5fd0fe94667160ffe97f70a42c3cc18388d86098cc"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==3.8.0"
|
||||
},
|
||||
"priority": {
|
||||
"hashes": [
|
||||
"sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe",
|
||||
"sha256:be4fcb94b5e37cdeb40af5533afe6dd603bd665fe9c8b3052610fc1001d5d1eb"
|
||||
],
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"pydantic": {
|
||||
"hashes": [
|
||||
"sha256:1783c1d927f9e1366e0e0609ae324039b2479a1a282a98ed6a6836c9ed02002c",
|
||||
"sha256:2dc946b07cf24bee4737ced0ae77e2ea6bc97489ba5a035b603bd1b40ad81f7e",
|
||||
"sha256:2de562a456c4ecdc80cf1a8c3e70c666625f7d02d89a6174ecf63754c734592e",
|
||||
"sha256:36dbf6f1be212ab37b5fda07667461a9219c956181aa5570a00edfb0acdfe4a1",
|
||||
"sha256:3fa799f3cfff3e5f536cbd389368fc96a44bb30308f258c94ee76b73bd60531d",
|
||||
"sha256:40d765fa2d31d5be8e29c1794657ad46f5ee583a565c83cea56630d3ae5878b9",
|
||||
"sha256:418b84654b60e44c0cdd5384294b0e4bc1ebf42d6e873819424f3b78b8690614",
|
||||
"sha256:4900b8820b687c9a3ed753684337979574df20e6ebe4227381d04b3c3c628f99",
|
||||
"sha256:530d7222a2786a97bc59ee0e0ebbe23728f82974b1f1ad9a11cd966143410633",
|
||||
"sha256:54122a8ed6b75fe1dd80797f8251ad2063ea348a03b77218d73ea9fe19bd4e73",
|
||||
"sha256:6c3f162ba175678218629f446a947e3356415b6b09122dcb364e58c442c645a7",
|
||||
"sha256:b49c86aecde15cde33835d5d6360e55f5e0067bb7143a8303bf03b872935c75b",
|
||||
"sha256:b5b3489cb303d0f41ad4a7390cf606a5f2c7a94dcba20c051cd1c653694cb14d",
|
||||
"sha256:cf3933c98cb5e808b62fae509f74f209730b180b1e3c3954ee3f7949e083a7df",
|
||||
"sha256:eb75dc1809875d5738df14b6566ccf9fd9c0bcde4f36b72870f318f16b9f5c20",
|
||||
"sha256:f769141ab0abfadf3305d4fcf36660e5cf568a666dd3efab7c3d4782f70946b1",
|
||||
"sha256:f8af9b840a9074e08c0e6dc93101de84ba95df89b267bf7151d74c553d66833b"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==1.6.1"
|
||||
},
|
||||
"pyscss": {
|
||||
"hashes": [
|
||||
"sha256:f1df571569021a23941a538eb154405dde80bed35dc1ea7c5f3e18e0144746bf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.7"
|
||||
},
|
||||
"python-dotenv": {
|
||||
"hashes": [
|
||||
"sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d",
|
||||
"sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"
|
||||
],
|
||||
"version": "==0.14.0"
|
||||
},
|
||||
"quart": {
|
||||
"hashes": [
|
||||
"sha256:9c634e4c1e4b21b824003c676de1583581258c72b0ac4d2ba747db846e97ff56",
|
||||
"sha256:d885d782edd9d5dcfd2c4a56e020db3b82493d4c3950f91c221b7d88d239ac93"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.13.1"
|
||||
},
|
||||
"quart-compress": {
|
||||
"hashes": [
|
||||
"sha256:41cd0cc8d26905a45025ddda7022461a71b9d1d950b21b006dc106a1c41c75ef",
|
||||
"sha256:63af5e6370aa7850fb219d22e1db89965aeb13b8f27bc83e7f9a44118faa3c54"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.2.1"
|
||||
},
|
||||
"quart-cors": {
|
||||
"hashes": [
|
||||
"sha256:020a17d504264db86cada3c1335ef174af28b33f57cee321ddc46d69c33d5c8e",
|
||||
"sha256:c08bdb326219b6c186d19ed6a97a7fd02de8fe36c7856af889494c69b525c53c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
|
||||
"sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.24.0"
|
||||
},
|
||||
"secure": {
|
||||
"hashes": [
|
||||
"sha256:4dc8dd4b548831c3ad7f94079332c41d67c781eccc32215ff5a8a49582c1a447",
|
||||
"sha256:b3bf1e39ebf40040fc3248392343a5052aa14cb45fc87ec91b0bd11f19cc46bd"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.2.1"
|
||||
},
|
||||
"shortuuid": {
|
||||
"hashes": [
|
||||
"sha256:3c11d2007b915c43bee3e10625f068d8a349e04f0d81f08f5fa08507427ebf1f",
|
||||
"sha256:492c7402ff91beb1342a5898bd61ea953985bf24a41cd9f247409aa2e03c8f77"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
|
||||
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
|
||||
],
|
||||
"version": "==0.10.1"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
|
||||
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
|
||||
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.7.4.3"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
|
||||
"sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==1.25.10"
|
||||
},
|
||||
"werkzeug": {
|
||||
"hashes": [
|
||||
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
|
||||
"sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"wsproto": {
|
||||
"hashes": [
|
||||
"sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d",
|
||||
"sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.1'",
|
||||
"version": "==0.15.0"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
"appdirs": {
|
||||
"hashes": [
|
||||
"sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
|
||||
"sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
|
||||
],
|
||||
"version": "==1.4.4"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
|
||||
"sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.2.0"
|
||||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
"sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==20.8b1"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
|
||||
"sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==7.1.2"
|
||||
},
|
||||
"coverage": {
|
||||
"hashes": [
|
||||
"sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
|
||||
"sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
|
||||
"sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
|
||||
"sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
|
||||
"sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
|
||||
"sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
|
||||
"sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
|
||||
"sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
|
||||
"sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
|
||||
"sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
|
||||
"sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
|
||||
"sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
|
||||
"sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
|
||||
"sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
|
||||
"sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
|
||||
"sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
|
||||
"sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
|
||||
"sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
|
||||
"sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
|
||||
"sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
|
||||
"sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
|
||||
"sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
|
||||
"sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
|
||||
"sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
|
||||
"sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
|
||||
"sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
|
||||
"sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
|
||||
"sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
|
||||
"sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
|
||||
"sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
|
||||
"sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
|
||||
"sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
|
||||
"sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
|
||||
"sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
|
||||
"version": "==5.3"
|
||||
},
|
||||
"iniconfig": {
|
||||
"hashes": [
|
||||
"sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
|
||||
"sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"
|
||||
],
|
||||
"version": "==1.0.1"
|
||||
},
|
||||
"more-itertools": {
|
||||
"hashes": [
|
||||
"sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
|
||||
"sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==8.5.0"
|
||||
},
|
||||
"mypy": {
|
||||
"hashes": [
|
||||
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
|
||||
"sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7",
|
||||
"sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2",
|
||||
"sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474",
|
||||
"sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0",
|
||||
"sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217",
|
||||
"sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749",
|
||||
"sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6",
|
||||
"sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf",
|
||||
"sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36",
|
||||
"sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b",
|
||||
"sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72",
|
||||
"sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1",
|
||||
"sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.761"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
|
||||
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
|
||||
],
|
||||
"version": "==0.4.3"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
|
||||
"sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==20.4"
|
||||
},
|
||||
"pathspec": {
|
||||
"hashes": [
|
||||
"sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
|
||||
"sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
|
||||
],
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
|
||||
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==0.13.1"
|
||||
},
|
||||
"py": {
|
||||
"hashes": [
|
||||
"sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
|
||||
"sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
|
||||
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.4.7"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40",
|
||||
"sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==6.0.2"
|
||||
},
|
||||
"pytest-asyncio": {
|
||||
"hashes": [
|
||||
"sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d",
|
||||
"sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==0.14.0"
|
||||
},
|
||||
"pytest-cov": {
|
||||
"hashes": [
|
||||
"sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191",
|
||||
"sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.10.1"
|
||||
},
|
||||
"regex": {
|
||||
"hashes": [
|
||||
"sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204",
|
||||
"sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162",
|
||||
"sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f",
|
||||
"sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb",
|
||||
"sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6",
|
||||
"sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7",
|
||||
"sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88",
|
||||
"sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99",
|
||||
"sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644",
|
||||
"sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a",
|
||||
"sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840",
|
||||
"sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067",
|
||||
"sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd",
|
||||
"sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4",
|
||||
"sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e",
|
||||
"sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89",
|
||||
"sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e",
|
||||
"sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc",
|
||||
"sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf",
|
||||
"sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341",
|
||||
"sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"
|
||||
],
|
||||
"version": "==2020.7.14"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
|
||||
"sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.15.0"
|
||||
},
|
||||
"toml": {
|
||||
"hashes": [
|
||||
"sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
|
||||
"sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
|
||||
],
|
||||
"version": "==0.10.1"
|
||||
},
|
||||
"typed-ast": {
|
||||
"hashes": [
|
||||
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
|
||||
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
|
||||
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
|
||||
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
|
||||
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
|
||||
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
|
||||
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
|
||||
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
|
||||
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
|
||||
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
|
||||
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
|
||||
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
|
||||
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
|
||||
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
|
||||
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
|
||||
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
|
||||
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
|
||||
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
|
||||
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
|
||||
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
|
||||
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
|
||||
],
|
||||
"version": "==1.4.1"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
|
||||
"sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
|
||||
"sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.7.4.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,80 @@
|
||||
LNbits
|
||||
======
|
||||
|
||||

|
||||
# LNbits
|
||||
Simple free and open-source Python lightning-network wallet/accounts system. Use https://lnbits.com, or run your own LNbits server!
|
||||
[![github-tests-badge]][github-tests]
|
||||
[![github-mypy-badge]][github-mypy]
|
||||
[![codecov-badge]][codecov]
|
||||
[![license-badge]](LICENSE)
|
||||
[![docs-badge]][docs]
|
||||
|
||||
LNbits is a very simple server that sits on top of a 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!
|
||||
|
||||

|
||||
|
||||
# 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
|
||||
* 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 such as LND, lntxbot, paywall, opennode, etc. This first BETA release runs on top of lntxbot, other releases coming soon, although if you are impatient, it could be ported to other funding sources relatively easily. Contributors very welcome :)
|
||||
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.
|
||||
|
||||
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 locally
|
||||
|
||||
See the [development docs](docs/devs/installation.md) for details on installation and setup.
|
||||
|
||||
LNbits is still in BETA. Please report any vulnerabilities responsibly
|
||||
## 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..
|
||||
|
||||

|
||||
|
||||

|
||||
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 **/lnurl?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 me
|
||||
|
||||
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 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
|
||||
|
||||
@@ -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,60 @@
|
||||
---
|
||||
layout: default
|
||||
parent: For developers
|
||||
title: Installation
|
||||
nav_order: 1
|
||||
---
|
||||
|
||||
|
||||
Installation
|
||||
============
|
||||
|
||||
Download this repo and install the dependencies.
|
||||
|
||||
|
||||
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 you wish to use a version of Python higher than 3.7:
|
||||
|
||||
```sh
|
||||
$ pipenv --python 3.8 install --dev
|
||||
```
|
||||
|
||||
You will need to set the variables in `.env.example`, and rename the file to `.env`.
|
||||
|
||||

|
||||
|
||||
You 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 lnd-grpc`.
|
||||
|
||||
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,33 @@
|
||||
---
|
||||
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
|
||||
$ python3 -m venv .venv
|
||||
$ source ./.venv/bin/activate
|
||||
(.venv) $ pip install -r requirements.txt
|
||||
```
|
||||
|
||||
You will need to set the variables in `.env.example`, and rename the file to `.env`.
|
||||
|
||||
Run the server:
|
||||
|
||||
```sh
|
||||
(.venv) $ python -m lnbits
|
||||
```
|
||||
|
||||
You might also need to install additional packages, depending on the [backend wallet](./wallets.md) you use.
|
||||
E.g. when you want to use LND you have to run:
|
||||
|
||||
```sh
|
||||
(.venv) $ pip install lnd-grpc
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
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 `lnd-grpc` Python package.
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndWallet**
|
||||
- `LND_GRPC_ENDPOINT`: ip_address
|
||||
- `LND_GRPC_PORT`: port
|
||||
- `LND_CERT`: /file/path/tls.cert
|
||||
- `LND_ADMIN_MACAROON`: /file/path/admin.macaroon
|
||||
- `LND_INVOICE_MACAROON`: /file/path/invoice.macaroon
|
||||
- `LND_READ_MACAROON`: /file/path/read.macaroon
|
||||
|
||||
|
||||
### LND (REST)
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LndRestWallet**
|
||||
- `LND_REST_ENDPOINT`: ip_address
|
||||
- `LND_CERT`: /file/path/tls.cert
|
||||
- `LND_ADMIN_MACAROON`: /file/path/admin.macaroon
|
||||
- `LND_INVOICE_MACAROON`: /file/path/invoice.macaroon
|
||||
- `LND_READ_MACAROON`: /file/path/read.macaroon
|
||||
|
||||
|
||||
### LNbits
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNbitsWallet**
|
||||
- `LNBITS_ENDPOINT`: e.g. https://lnbits.com
|
||||
- `LNBITS_ADMIN_KEY`: apiKey
|
||||
- `LNBITS_INVOICE_KEY`: apiKey
|
||||
|
||||
|
||||
### LNPay
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LNPayWallet**
|
||||
- `LNPAY_API_ENDPOINT`: https://lnpay.co/v1/
|
||||
- `LNPAY_API_KEY`: apiKey
|
||||
- `LNPAY_ADMIN_KEY`: apiKey
|
||||
- `LNPAY_INVOICE_KEY`: apiKey
|
||||
- `LNPAY_READ_KEY`: apiKey
|
||||
|
||||
|
||||
### lntxbot
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **LntxbotWallet**
|
||||
- `LNTXBOT_API_ENDPOINT`: https://lntxbot.bigsun.xyz/
|
||||
- `LNTXBOT_ADMIN_KEY`: apiKey
|
||||
- `LNTXBOT_INVOICE_KEY`: apiKey
|
||||
|
||||
|
||||
### OpenNode
|
||||
|
||||
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_ADMIN_KEY`: apiKey
|
||||
- `OPENNODE_INVOICE_KEY`: apiKey
|
||||
@@ -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...
|
||||
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g fill="#eee" font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><g transform="translate(-2.9464 -5.625) scale(.17717)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 314 B |
@@ -1,503 +0,0 @@
|
||||
import importlib
|
||||
import json
|
||||
import requests
|
||||
import uuid
|
||||
|
||||
from flask import Flask, jsonify, redirect, render_template, request, url_for
|
||||
from flask_talisman import Talisman
|
||||
from lnurl import Lnurl, LnurlWithdrawResponse
|
||||
|
||||
from . import bolt11
|
||||
from .core import core_app
|
||||
from .db import init_databases, open_db
|
||||
from .helpers import ExtensionManager, megajson
|
||||
from .settings import WALLET, DEFAULT_USER_WALLET_NAME, FEE_RESERVE
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
valid_extensions = [ext for ext in ExtensionManager().extensions if ext.is_valid]
|
||||
|
||||
|
||||
# optimization & security
|
||||
# -----------------------
|
||||
|
||||
Talisman(
|
||||
app,
|
||||
content_security_policy={
|
||||
"default-src": [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
"cdnjs.cloudflare.com",
|
||||
"code.ionicframework.com",
|
||||
"code.jquery.com",
|
||||
"fonts.googleapis.com",
|
||||
"fonts.gstatic.com",
|
||||
"maxcdn.bootstrapcdn.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["EXTENSIONS"] = valid_extensions
|
||||
app.jinja_env.filters["megajson"] = megajson
|
||||
|
||||
|
||||
# init
|
||||
# ----
|
||||
|
||||
@app.before_first_request
|
||||
def init():
|
||||
init_databases()
|
||||
|
||||
|
||||
# vvvvvvvvvvvvvvvvvvvvvvvvvvv
|
||||
# move the rest to `core_app`
|
||||
# vvvvvvvvvvvvvvvvvvvvvvvvvvv
|
||||
# vvvvvvvvvvvvvvvvvvvvvvvvvvv
|
||||
|
||||
|
||||
@app.route("/deletewallet")
|
||||
def deletewallet():
|
||||
user_id = request.args.get("usr")
|
||||
wallet_id = request.args.get("wal")
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
next_wallet = db.fetchone("SELECT id FROM wallets WHERE user = ?", (user_id,))
|
||||
|
||||
if next_wallet:
|
||||
return redirect(url_for("wallet", usr=user_id, wal=next_wallet[0]))
|
||||
|
||||
return redirect(url_for("home"))
|
||||
|
||||
|
||||
@app.route("/lnurl")
|
||||
def lnurl():
|
||||
lnurl = request.args.get("lightning")
|
||||
return render_template("lnurl.html", lnurl=lnurl)
|
||||
|
||||
|
||||
@app.route("/lnurlwallet")
|
||||
def lnurlwallet():
|
||||
lnurl = Lnurl(request.args.get("lightning"))
|
||||
r = requests.get(lnurl.url)
|
||||
if not r.ok:
|
||||
return redirect(url_for("home"))
|
||||
|
||||
data = json.loads(r.text)
|
||||
if data.get("status") == "ERROR":
|
||||
return redirect(url_for("home"))
|
||||
|
||||
withdraw_res = LnurlWithdrawResponse(**data)
|
||||
|
||||
_, pay_hash, pay_req = WALLET.create_invoice(withdraw_res.max_sats, "LNbits lnurl funding")
|
||||
|
||||
r = requests.get(
|
||||
withdraw_res.callback.base,
|
||||
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": pay_req}},
|
||||
)
|
||||
|
||||
if not r.ok:
|
||||
return redirect(url_for("home"))
|
||||
data = json.loads(r.text)
|
||||
|
||||
for i in range(10):
|
||||
r = WALLET.get_invoice_status(pay_hash).raw_response
|
||||
if not r.ok:
|
||||
continue
|
||||
|
||||
data = r.json()
|
||||
break
|
||||
|
||||
with open_db() as db:
|
||||
wallet_id = uuid.uuid4().hex
|
||||
user_id = uuid.uuid4().hex
|
||||
wallet_name = DEFAULT_USER_WALLET_NAME
|
||||
adminkey = uuid.uuid4().hex
|
||||
inkey = uuid.uuid4().hex
|
||||
|
||||
db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
|
||||
db.execute(
|
||||
"INSERT INTO wallets (id, name, user, adminkey, inkey) VALUES (?, ?, ?, ?, ?)",
|
||||
(wallet_id, wallet_name, user_id, adminkey, inkey),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO apipayments (payhash, amount, wallet, pending, memo) VALUES (?, ?, ?, 0, ?)",
|
||||
(pay_hash, withdraw_res.max_sats * 1000, wallet_id, "LNbits lnurl funding",),
|
||||
)
|
||||
|
||||
return redirect(url_for("wallet", usr=user_id, wal=wallet_id))
|
||||
|
||||
|
||||
@app.route("/wallet")
|
||||
def wallet():
|
||||
usr = request.args.get("usr")
|
||||
wallet_id = request.args.get("wal")
|
||||
wallet_name = request.args.get("nme")
|
||||
|
||||
if usr:
|
||||
if not len(usr) > 20:
|
||||
return redirect(url_for("home"))
|
||||
if wallet_id:
|
||||
if not len(wallet_id) > 20:
|
||||
return redirect(url_for("home"))
|
||||
|
||||
# just usr: return a the first user wallet or create one if none found
|
||||
# usr and wallet_id: return that wallet or create it if it doesn't exist
|
||||
# usr, wallet_id and wallet_name: same as above, but use the specified name
|
||||
# usr and wallet_name: generate a wallet_id and create
|
||||
# wallet_id and wallet_name: create a user, then move an existing wallet or create
|
||||
# just wallet_name: create a user, then generate a wallet_id and create
|
||||
# nothing: create everything
|
||||
|
||||
with open_db() as db:
|
||||
# ensure this user exists
|
||||
# -------------------------------
|
||||
|
||||
if not usr:
|
||||
usr = uuid.uuid4().hex
|
||||
return redirect(url_for("wallet", usr=usr, wal=wallet_id, nme=wallet_name))
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO accounts (id) VALUES (?)
|
||||
""",
|
||||
(usr,),
|
||||
)
|
||||
|
||||
user_wallets = db.fetchall("SELECT * FROM wallets WHERE user = ?", (usr,))
|
||||
|
||||
if not wallet_id:
|
||||
if user_wallets and not wallet_name:
|
||||
# fetch the first wallet from this user
|
||||
# -------------------------------------
|
||||
wallet_id = user_wallets[0]["id"]
|
||||
else:
|
||||
# create for this user
|
||||
# --------------------
|
||||
wallet_name = wallet_name or DEFAULT_USER_WALLET_NAME
|
||||
wallet_id = uuid.uuid4().hex
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO wallets (id, name, user, adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(wallet_id, wallet_name, usr, uuid.uuid4().hex, uuid.uuid4().hex),
|
||||
)
|
||||
|
||||
return redirect(url_for("wallet", usr=usr, wal=wallet_id, nme=wallet_name))
|
||||
|
||||
# if wallet_id is given, try to move it to this user or create
|
||||
# ------------------------------------------------------------
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO wallets (id, user, name, adminkey, inkey)
|
||||
VALUES (?, ?,
|
||||
coalesce((SELECT name FROM wallets WHERE id = ?), ?),
|
||||
coalesce((SELECT adminkey FROM wallets WHERE id = ?), ?),
|
||||
coalesce((SELECT inkey FROM wallets WHERE id = ?), ?)
|
||||
)
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
usr,
|
||||
wallet_id,
|
||||
wallet_name or DEFAULT_USER_WALLET_NAME,
|
||||
wallet_id,
|
||||
uuid.uuid4().hex,
|
||||
wallet_id,
|
||||
uuid.uuid4().hex,
|
||||
),
|
||||
)
|
||||
|
||||
# finally, get the wallet with balance and transactions
|
||||
# -----------------------------------------------------
|
||||
|
||||
wallet = db.fetchone(
|
||||
"""
|
||||
SELECT
|
||||
coalesce((SELECT balance/1000 FROM balances WHERE wallet = wallets.id), 0) * ? AS balance,
|
||||
*
|
||||
FROM wallets
|
||||
WHERE user = ? AND id = ?
|
||||
""",
|
||||
(1 - FEE_RESERVE, usr, wallet_id),
|
||||
)
|
||||
|
||||
transactions = db.fetchall(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet = ? AND pending = 0
|
||||
ORDER BY time
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
|
||||
user_ext = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (usr,))
|
||||
user_ext = [v[0] for v in user_ext]
|
||||
|
||||
return render_template(
|
||||
"wallet.html", user_wallets=user_wallets, wallet=wallet, user=usr, transactions=transactions, user_ext=user_ext
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/v1/invoices", methods=["GET", "POST"])
|
||||
def api_invoices():
|
||||
if request.headers["Content-Type"] != "application/json":
|
||||
return jsonify({"ERROR": "MUST BE JSON"}), 400
|
||||
|
||||
postedjson = request.json
|
||||
|
||||
# Form validation
|
||||
if int(postedjson["value"]) < 0 or not postedjson["memo"].replace(" ", "").isalnum():
|
||||
return jsonify({"ERROR": "FORM ERROR"}), 401
|
||||
|
||||
if "value" not in postedjson:
|
||||
return jsonify({"ERROR": "NO VALUE"}), 400
|
||||
|
||||
if not postedjson["value"].isdigit():
|
||||
return jsonify({"ERROR": "VALUE MUST BE A NUMBER"}), 400
|
||||
|
||||
if int(postedjson["value"]) < 0:
|
||||
return jsonify({"ERROR": "AMOUNTLESS INVOICES NOT SUPPORTED"}), 400
|
||||
|
||||
if "memo" not in postedjson:
|
||||
return jsonify({"ERROR": "NO MEMO"}), 400
|
||||
|
||||
with open_db() as db:
|
||||
wallet = db.fetchone(
|
||||
"SELECT id FROM wallets WHERE inkey = ? OR adminkey = ?",
|
||||
(request.headers["Grpc-Metadata-macaroon"], request.headers["Grpc-Metadata-macaroon"],),
|
||||
)
|
||||
|
||||
if not wallet:
|
||||
return jsonify({"ERROR": "NO KEY"}), 200
|
||||
|
||||
r, pay_hash, pay_req = WALLET.create_invoice(postedjson["value"], postedjson["memo"])
|
||||
|
||||
if not r.ok or "error" in r.json():
|
||||
return jsonify({"ERROR": "UNEXPECTED BACKEND ERROR"}), 500
|
||||
|
||||
amount_msat = int(postedjson["value"]) * 1000
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO apipayments (payhash, amount, wallet, pending, memo) VALUES (?, ?, ?, 1, ?)",
|
||||
(pay_hash, amount_msat, wallet["id"], postedjson["memo"],),
|
||||
)
|
||||
|
||||
return jsonify({"pay_req": pay_req, "payment_hash": pay_hash}), 200
|
||||
|
||||
|
||||
@app.route("/api/v1/channels/transactions", methods=["GET", "POST"])
|
||||
def api_transactions():
|
||||
|
||||
if request.headers["Content-Type"] != "application/json":
|
||||
return jsonify({"ERROR": "MUST BE JSON"}), 400
|
||||
|
||||
data = request.json
|
||||
|
||||
print(data)
|
||||
if "payment_request" not in data:
|
||||
return jsonify({"ERROR": "NO PAY REQ"}), 400
|
||||
|
||||
with open_db() as db:
|
||||
wallet = db.fetchone("SELECT id FROM wallets WHERE adminkey = ?", (request.headers["Grpc-Metadata-macaroon"],))
|
||||
|
||||
if not wallet:
|
||||
return jsonify({"ERROR": "BAD AUTH"}), 401
|
||||
|
||||
# decode the invoice
|
||||
invoice = bolt11.decode(data["payment_request"])
|
||||
if invoice.amount_msat == 0:
|
||||
return jsonify({"ERROR": "AMOUNTLESS INVOICES NOT SUPPORTED"}), 400
|
||||
|
||||
# insert the payment
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO apipayments (payhash, amount, fee, wallet, pending, memo) VALUES (?, ?, ?, ?, 1, ?)",
|
||||
(
|
||||
invoice.payment_hash,
|
||||
-int(invoice.amount_msat),
|
||||
-int(invoice.amount_msat) * FEE_RESERVE,
|
||||
wallet["id"],
|
||||
invoice.description,
|
||||
),
|
||||
)
|
||||
|
||||
# check balance
|
||||
balance = db.fetchone("SELECT balance/1000 FROM balances WHERE wallet = ?", (wallet["id"],))[0]
|
||||
if balance < 0:
|
||||
db.execute("DELETE FROM apipayments WHERE payhash = ? AND wallet = ?", (invoice.payment_hash, wallet["id"]))
|
||||
return jsonify({"ERROR": "INSUFFICIENT BALANCE"}), 403
|
||||
|
||||
# check if the invoice is an internal one
|
||||
if db.fetchone("SELECT count(*) FROM apipayments WHERE payhash = ?", (invoice.payment_hash,))[0] == 2:
|
||||
# internal. mark both sides as fulfilled.
|
||||
db.execute("UPDATE apipayments SET pending = 0, fee = 0 WHERE payhash = ?", (invoice.payment_hash,))
|
||||
else:
|
||||
# actually send the payment
|
||||
r = WALLET.pay_invoice(data["payment_request"])
|
||||
|
||||
if not r.raw_response.ok or r.failed:
|
||||
return jsonify({"ERROR": "UNEXPECTED PAYMENT ERROR"}), 500
|
||||
|
||||
# payment went through, not pending anymore, save actual fees
|
||||
db.execute(
|
||||
"UPDATE apipayments SET pending = 0, fee = ? WHERE payhash = ? AND wallet = ?",
|
||||
(r.fee_msat, invoice.payment_hash, wallet["id"]),
|
||||
)
|
||||
|
||||
return jsonify({"PAID": "TRUE", "payment_hash": invoice.payment_hash}), 200
|
||||
|
||||
|
||||
@app.route("/api/v1/invoice/<payhash>", methods=["GET"])
|
||||
def api_checkinvoice(payhash):
|
||||
if request.headers["Content-Type"] != "application/json":
|
||||
return jsonify({"ERROR": "MUST BE JSON"}), 400
|
||||
|
||||
|
||||
with open_db() as db:
|
||||
payment = db.fetchall("SELECT * FROM apipayments WHERE payhash = ?", (payhash,))
|
||||
|
||||
if not payment:
|
||||
return jsonify({"ERROR": "NO INVOICE"}), 404
|
||||
|
||||
if not payment[0][4]: # pending
|
||||
return jsonify({"PAID": "TRUE"}), 200
|
||||
|
||||
if not WALLET.get_invoice_status(payhash).settled:
|
||||
return jsonify({"PAID": "FALSE"}), 200
|
||||
|
||||
db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,))
|
||||
return jsonify({"PAID": "TRUE"}), 200
|
||||
|
||||
|
||||
@app.route("/api/v1/payment/<payhash>", methods=["GET"])
|
||||
def api_checkpayment(payhash):
|
||||
if request.headers["Content-Type"] != "application/json":
|
||||
return jsonify({"ERROR": "MUST BE JSON"}), 400
|
||||
|
||||
with open_db() as db:
|
||||
payment = db.fetchone(
|
||||
"""
|
||||
SELECT pending
|
||||
FROM apipayments
|
||||
INNER JOIN wallets AS w ON apipayments.wallet = w.id
|
||||
WHERE payhash = ?
|
||||
AND (w.adminkey = ? OR w.inkey = ?)
|
||||
""",
|
||||
(payhash, request.headers["Grpc-Metadata-macaroon"], request.headers["Grpc-Metadata-macaroon"]),
|
||||
)
|
||||
|
||||
if not payment:
|
||||
return jsonify({"ERROR": "NO INVOICE"}), 404
|
||||
|
||||
if not payment["pending"]: # pending
|
||||
return jsonify({"PAID": "TRUE"}), 200
|
||||
|
||||
if not WALLET.get_payment_status(payhash).settled:
|
||||
return jsonify({"PAID": "FALSE"}), 200
|
||||
|
||||
db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,))
|
||||
return jsonify({"PAID": "TRUE"}), 200
|
||||
|
||||
|
||||
@app.route("/api/v1/checkpending", methods=["POST"])
|
||||
def api_checkpending():
|
||||
with open_db() as db:
|
||||
for pendingtx in db.fetchall(
|
||||
"""
|
||||
SELECT
|
||||
payhash,
|
||||
CASE
|
||||
WHEN amount < 0 THEN 'send'
|
||||
ELSE 'recv'
|
||||
END AS kind
|
||||
FROM apipayments
|
||||
INNER JOIN wallets ON apipayments.wallet = wallets.id
|
||||
WHERE time > strftime('%s', 'now') - 86400
|
||||
AND pending = 1
|
||||
AND (adminkey = ? OR inkey = ?)
|
||||
""",
|
||||
(request.headers["Grpc-Metadata-macaroon"], request.headers["Grpc-Metadata-macaroon"]),
|
||||
):
|
||||
payhash = pendingtx["payhash"]
|
||||
kind = pendingtx["kind"]
|
||||
|
||||
if kind == "send":
|
||||
payment_complete = WALLET.get_payment_status(payhash).settled
|
||||
if payment_complete:
|
||||
db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,))
|
||||
elif payment_complete is False:
|
||||
db.execute("DELETE FROM apipayments WHERE payhash = ?", (payhash,))
|
||||
|
||||
elif kind == "recv" and WALLET.get_invoice_status(payhash).settled:
|
||||
db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,))
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# Checks DB to see if the extensions are activated or not activated for the user
|
||||
@app.route("/extensions")
|
||||
def extensions():
|
||||
usr = request.args.get("usr")
|
||||
enable = request.args.get("enable")
|
||||
disable = request.args.get("disable")
|
||||
ext = None
|
||||
|
||||
if usr and not len(usr) > 20:
|
||||
return redirect(url_for("home"))
|
||||
|
||||
if enable and disable:
|
||||
# TODO: show some kind of error
|
||||
return redirect(url_for("extensions"))
|
||||
|
||||
with open_db() as db:
|
||||
user_wallets = db.fetchall("SELECT * FROM wallets WHERE user = ?", (usr,))
|
||||
|
||||
if enable:
|
||||
ext, value = enable, 1
|
||||
if disable:
|
||||
ext, value = disable, 0
|
||||
|
||||
if ext:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO extensions (user, extension, active)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(usr, ext, value),
|
||||
)
|
||||
|
||||
user_ext = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (usr,))
|
||||
user_ext = [v[0] for v in user_ext]
|
||||
|
||||
return render_template("extensions.html", user_wallets=user_wallets, user=usr, user_ext=user_ext)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .app import create_app
|
||||
from .commands import migrate_databases, transpile_scss, bundle_vendored
|
||||
|
||||
migrate_databases()
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
app = create_app()
|
||||
app.run(host=app.config["HOST"], port=app.config["PORT"])
|
||||
@@ -0,0 +1,87 @@
|
||||
import importlib
|
||||
|
||||
from quart import Quart, g
|
||||
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
|
||||
from .core import core_app
|
||||
from .db import open_db
|
||||
from .helpers import get_valid_extensions, get_js_vendored, get_css_vendored, url_for_vendored
|
||||
from .proxy_fix import ProxyFix
|
||||
|
||||
secure_headers = SecureHeaders(hsts=False)
|
||||
|
||||
|
||||
def create_app(config_object="lnbits.settings") -> Quart:
|
||||
"""Create application factory.
|
||||
:param config_object: The configuration object to use.
|
||||
"""
|
||||
app = Quart(__name__, static_folder="static")
|
||||
app.config.from_object(config_object)
|
||||
|
||||
cors(app)
|
||||
Compress(app)
|
||||
ProxyFix(app, x_proto=1, x_host=1)
|
||||
|
||||
register_assets(app)
|
||||
register_blueprints(app)
|
||||
register_filters(app)
|
||||
register_commands(app)
|
||||
register_request_hooks(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def register_blueprints(app: Quart) -> 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}")
|
||||
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.")
|
||||
|
||||
|
||||
def register_commands(app: Quart):
|
||||
"""Register Click commands."""
|
||||
app.cli.add_command(db_migrate)
|
||||
|
||||
|
||||
def register_assets(app: Quart):
|
||||
"""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: Quart):
|
||||
"""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: Quart):
|
||||
"""Open the core db for each request so everything happens in a big transaction"""
|
||||
|
||||
@app.before_request
|
||||
async def before_request():
|
||||
g.db = open_db()
|
||||
|
||||
@app.after_request
|
||||
async def set_secure_headers(response):
|
||||
secure_headers.quart(response)
|
||||
return response
|
||||
|
||||
@app.teardown_request
|
||||
async def after_request(exc):
|
||||
g.db.__exit__(type(exc), exc, None)
|
||||
@@ -1,64 +1,121 @@
|
||||
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:
|
||||
#
|
||||
@@ -81,18 +138,18 @@ def unshorten_amount(amount: str) -> int:
|
||||
raise ValueError("Invalid amount '{}'".format(amount))
|
||||
|
||||
if unit in units:
|
||||
return int(amount[:-1]) * 100_000_000_000 / units[unit]
|
||||
return int(int(amount[:-1]) * 100_000_000_000 / units[unit])
|
||||
else:
|
||||
return int(amount) * 100_000_000_000
|
||||
|
||||
|
||||
def pull_tagged(stream):
|
||||
def _pull_tagged(stream):
|
||||
tag = stream.read(5).uint
|
||||
length = stream.read(5).uint * 32 + stream.read(5).uint
|
||||
return (CHARSET[tag], stream.read(length * 5), stream)
|
||||
|
||||
|
||||
def trim_to_bytes(barr):
|
||||
def _trim_to_bytes(barr):
|
||||
# Adds a byte if necessary.
|
||||
b = barr.tobytes()
|
||||
if barr.len % 8 != 0:
|
||||
@@ -100,7 +157,15 @@ def trim_to_bytes(barr):
|
||||
return b
|
||||
|
||||
|
||||
def u5_to_bitarray(arr):
|
||||
def _readable_scid(short_channel_id: int) -> str:
|
||||
return "{blockheight}x{transactionindex}x{outputindex}".format(
|
||||
blockheight=((short_channel_id >> 40) & 0xFFFFFF),
|
||||
transactionindex=((short_channel_id >> 16) & 0xFFFFFF),
|
||||
outputindex=(short_channel_id & 0xFFFF),
|
||||
)
|
||||
|
||||
|
||||
def _u5_to_bitarray(arr: List[int]) -> bitstring.BitArray:
|
||||
ret = bitstring.BitArray()
|
||||
for a in arr:
|
||||
ret += bitstring.pack("uint:5", a)
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import click
|
||||
import importlib
|
||||
import re
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from scss.compiler import compile_string # type: ignore
|
||||
|
||||
from .core import migrations as core_migrations
|
||||
from .db import open_db, open_ext_db
|
||||
from .helpers import get_valid_extensions, get_css_vendored, get_js_vendored, url_for_vendored
|
||||
from .settings import LNBITS_PATH
|
||||
|
||||
|
||||
@click.command("migrate")
|
||||
def db_migrate():
|
||||
migrate_databases()
|
||||
|
||||
|
||||
@click.command("assets")
|
||||
def handle_assets():
|
||||
transpile_scss()
|
||||
bundle_vendored()
|
||||
|
||||
|
||||
def transpile_scss():
|
||||
with open(os.path.join(LNBITS_PATH, "static/scss/base.scss")) as scss:
|
||||
with open(os.path.join(LNBITS_PATH, "static/css/base.css"), "w") as css:
|
||||
css.write(compile_string(scss.read()))
|
||||
|
||||
|
||||
def bundle_vendored():
|
||||
for getfiles, outputpath in [
|
||||
(get_js_vendored, os.path.join(LNBITS_PATH, "static/bundle.js")),
|
||||
(get_css_vendored, os.path.join(LNBITS_PATH, "static/bundle.css")),
|
||||
]:
|
||||
output = ""
|
||||
for path in getfiles():
|
||||
with open(path) as f:
|
||||
output += "/* " + url_for_vendored(path) + " */\n" + f.read() + ";\n"
|
||||
with open(outputpath, "w") as f:
|
||||
f.write(output)
|
||||
|
||||
|
||||
def migrate_databases():
|
||||
"""Creates the necessary databases if they don't exist already; or migrates them."""
|
||||
|
||||
with open_db() as core_db:
|
||||
try:
|
||||
rows = core_db.fetchall("SELECT * FROM dbversions")
|
||||
except sqlite3.OperationalError:
|
||||
# migration 3 wasn't ran
|
||||
core_migrations.m000_create_migrations_table(core_db)
|
||||
rows = core_db.fetchall("SELECT * FROM dbversions")
|
||||
|
||||
current_versions = {row["db"]: row["version"] for row in rows}
|
||||
matcher = re.compile(r"^m(\d\d\d)_")
|
||||
|
||||
def run_migration(db, migrations_module):
|
||||
db_name = migrations_module.__name__.split(".")[-2]
|
||||
for key, run_migration in migrations_module.__dict__.items():
|
||||
match = match = matcher.match(key)
|
||||
if match:
|
||||
version = int(match.group(1))
|
||||
if version > current_versions.get(db_name, 0):
|
||||
print(f"running migration {db_name}.{version}")
|
||||
run_migration(db)
|
||||
core_db.execute(
|
||||
"INSERT OR REPLACE INTO dbversions (db, version) VALUES (?, ?)", (db_name, version)
|
||||
)
|
||||
|
||||
run_migration(core_db, core_migrations)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"lnbits.extensions.{ext.code}.migrations")
|
||||
with open_ext_db(ext.code) as db:
|
||||
run_migration(db, ext_migrations)
|
||||
except ImportError:
|
||||
raise ImportError(f"Please make sure that the extension `{ext.code}` has a migrations file.")
|
||||
@@ -1,8 +1,11 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
core_app = Blueprint("core", __name__, template_folder="templates")
|
||||
core_app: Blueprint = Blueprint(
|
||||
"core", __name__, template_folder="templates", static_folder="static", static_url_path="/core/static"
|
||||
)
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
from .views.api import * # noqa
|
||||
from .views.generic import * # noqa
|
||||
from .views.lnurl import * # noqa
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import json
|
||||
import datetime
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Dict
|
||||
from quart import g
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.settings import DEFAULT_WALLET_NAME
|
||||
|
||||
from .models import User, Wallet, Payment
|
||||
|
||||
|
||||
# accounts
|
||||
# --------
|
||||
|
||||
|
||||
def create_account() -> User:
|
||||
user_id = uuid4().hex
|
||||
g.db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
|
||||
|
||||
new_account = get_account(user_id=user_id)
|
||||
assert new_account, "Newly created account couldn't be retrieved"
|
||||
|
||||
return new_account
|
||||
|
||||
|
||||
def get_account(user_id: str) -> Optional[User]:
|
||||
row = g.db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,))
|
||||
|
||||
return User(**row) if row else None
|
||||
|
||||
|
||||
def get_user(user_id: str) -> Optional[User]:
|
||||
user = g.db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,))
|
||||
|
||||
if user:
|
||||
extensions = g.db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,))
|
||||
wallets = g.db.fetchall(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE user = ?
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
return (
|
||||
User(**{**user, **{"extensions": [e[0] for e in extensions], "wallets": [Wallet(**w) for w in wallets]}})
|
||||
if user
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
|
||||
g.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:
|
||||
wallet_id = uuid4().hex
|
||||
g.db.execute(
|
||||
"""
|
||||
INSERT INTO wallets (id, name, user, adminkey, inkey)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(wallet_id, wallet_name or DEFAULT_WALLET_NAME, user_id, uuid4().hex, uuid4().hex),
|
||||
)
|
||||
|
||||
new_wallet = get_wallet(wallet_id=wallet_id)
|
||||
assert new_wallet, "Newly created wallet couldn't be retrieved"
|
||||
|
||||
return new_wallet
|
||||
|
||||
|
||||
def delete_wallet(*, user_id: str, wallet_id: str) -> None:
|
||||
g.db.execute(
|
||||
"""
|
||||
UPDATE wallets AS w
|
||||
SET
|
||||
user = 'del:' || w.user,
|
||||
adminkey = 'del:' || w.adminkey,
|
||||
inkey = 'del:' || w.inkey
|
||||
WHERE id = ? AND user = ?
|
||||
""",
|
||||
(wallet_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
def get_wallet(wallet_id: str) -> Optional[Wallet]:
|
||||
row = g.db.fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE id = ?
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
|
||||
return Wallet(**row) if row else None
|
||||
|
||||
|
||||
def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
|
||||
row = g.db.fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
|
||||
FROM wallets
|
||||
WHERE adminkey = ? OR inkey = ?
|
||||
""",
|
||||
(key, key),
|
||||
)
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
if key_type == "admin" and row["adminkey"] != key:
|
||||
return None
|
||||
|
||||
return Wallet(**row)
|
||||
|
||||
|
||||
# wallet payments
|
||||
# ---------------
|
||||
|
||||
|
||||
def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
|
||||
row = g.db.fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet = ? AND hash = ?
|
||||
""",
|
||||
(wallet_id, payment_hash),
|
||||
)
|
||||
|
||||
return Payment.from_row(row) if row else None
|
||||
|
||||
|
||||
def get_wallet_payments(
|
||||
wallet_id: str,
|
||||
*,
|
||||
complete: bool = False,
|
||||
pending: bool = False,
|
||||
outgoing: bool = False,
|
||||
incoming: bool = False,
|
||||
exclude_uncheckable: bool = False,
|
||||
) -> List[Payment]:
|
||||
"""
|
||||
Filters payments to be returned by complete | pending | outgoing | incoming.
|
||||
"""
|
||||
|
||||
clause = ""
|
||||
if complete and pending:
|
||||
clause += ""
|
||||
elif complete:
|
||||
clause += "AND ((amount > 0 AND pending = 0) OR amount < 0)"
|
||||
elif pending:
|
||||
clause += "AND pending = 1"
|
||||
else:
|
||||
raise TypeError("at least one of [complete, pending] must be True.")
|
||||
|
||||
clause += " "
|
||||
|
||||
if outgoing and incoming:
|
||||
clause += ""
|
||||
elif outgoing:
|
||||
clause += "AND amount < 0"
|
||||
elif incoming:
|
||||
clause += "AND amount > 0"
|
||||
else:
|
||||
raise TypeError("at least one of [outgoing, incoming] must be True.")
|
||||
|
||||
clause += " "
|
||||
|
||||
if exclude_uncheckable: # checkable means it has a checking_id that isn't internal
|
||||
clause += "AND checking_id NOT LIKE 'temp_%' "
|
||||
clause += "AND checking_id NOT LIKE 'internal_%' "
|
||||
|
||||
rows = g.db.fetchall(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet = ? {clause}
|
||||
ORDER BY time DESC
|
||||
""",
|
||||
(wallet_id,),
|
||||
)
|
||||
|
||||
return [Payment.from_row(row) for row in rows]
|
||||
|
||||
|
||||
def delete_expired_invoices() -> None:
|
||||
rows = g.db.fetchall(
|
||||
"""
|
||||
SELECT bolt11
|
||||
FROM apipayments
|
||||
WHERE pending = 1 AND amount > 0 AND time < strftime('%s', 'now') - 86400
|
||||
"""
|
||||
)
|
||||
for (payment_request,) in rows:
|
||||
try:
|
||||
invoice = bolt11.decode(payment_request)
|
||||
except:
|
||||
continue
|
||||
|
||||
expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
if expiration_date > datetime.datetime.utcnow():
|
||||
continue
|
||||
|
||||
g.db.execute(
|
||||
"""
|
||||
DELETE FROM apipayments
|
||||
WHERE pending = 1 AND hash = ?
|
||||
""",
|
||||
(invoice.payment_hash,),
|
||||
)
|
||||
|
||||
|
||||
# payments
|
||||
# --------
|
||||
|
||||
|
||||
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,
|
||||
) -> Payment:
|
||||
g.db.execute(
|
||||
"""
|
||||
INSERT INTO apipayments
|
||||
(wallet, checking_id, bolt11, hash, preimage,
|
||||
amount, pending, memo, fee, extra)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
checking_id,
|
||||
payment_request,
|
||||
payment_hash,
|
||||
preimage,
|
||||
amount,
|
||||
int(pending),
|
||||
memo,
|
||||
fee,
|
||||
json.dumps(extra) if extra and extra != {} and type(extra) is dict else None,
|
||||
),
|
||||
)
|
||||
|
||||
new_payment = get_wallet_payment(wallet_id, payment_hash)
|
||||
assert new_payment, "Newly created payment couldn't be retrieved"
|
||||
|
||||
return new_payment
|
||||
|
||||
|
||||
def update_payment_status(checking_id: str, pending: bool) -> None:
|
||||
g.db.execute(
|
||||
"UPDATE apipayments SET pending = ? WHERE checking_id = ?",
|
||||
(
|
||||
int(pending),
|
||||
checking_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def delete_payment(checking_id: str) -> None:
|
||||
g.db.execute("DELETE FROM apipayments WHERE checking_id = ?", (checking_id,))
|
||||
|
||||
|
||||
def check_internal(payment_hash: str) -> Optional[str]:
|
||||
row = g.db.fetchone(
|
||||
"""
|
||||
SELECT checking_id FROM apipayments
|
||||
WHERE hash = ? AND pending AND amount > 0
|
||||
""",
|
||||
(payment_hash,),
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
else:
|
||||
return row["checking_id"]
|
||||
@@ -0,0 +1,122 @@
|
||||
import sqlite3
|
||||
|
||||
|
||||
def m000_create_migrations_table(db):
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE dbversions (
|
||||
db TEXT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def m001_initial(db):
|
||||
"""
|
||||
Initial LNbits tables.
|
||||
"""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
pass TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
user TEXT NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT 0,
|
||||
|
||||
UNIQUE (user, extension)
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
adminkey TEXT NOT NULL,
|
||||
inkey TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS apipayments (
|
||||
payhash TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
fee INTEGER NOT NULL DEFAULT 0,
|
||||
wallet TEXT NOT NULL,
|
||||
pending BOOLEAN NOT NULL,
|
||||
memo TEXT,
|
||||
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
|
||||
UNIQUE (wallet, payhash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE VIEW IF NOT EXISTS balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
FROM apipayments
|
||||
WHERE amount > 0 AND pending = 0 -- don't sum pending
|
||||
GROUP BY wallet
|
||||
UNION ALL
|
||||
SELECT wallet, SUM(amount + fee) AS s -- outgoing, sum fees
|
||||
FROM apipayments
|
||||
WHERE amount < 0 -- do sum pending
|
||||
GROUP BY wallet
|
||||
)
|
||||
GROUP BY wallet;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def m002_add_fields_to_apipayments(db):
|
||||
"""
|
||||
Adding fields to apipayments for better accounting,
|
||||
and renaming payhash to checking_id since that is what it really is.
|
||||
"""
|
||||
try:
|
||||
db.execute("ALTER TABLE apipayments RENAME COLUMN payhash TO checking_id")
|
||||
db.execute("ALTER TABLE apipayments ADD COLUMN hash TEXT")
|
||||
db.execute("CREATE INDEX by_hash ON apipayments (hash)")
|
||||
db.execute("ALTER TABLE apipayments ADD COLUMN preimage TEXT")
|
||||
db.execute("ALTER TABLE apipayments ADD COLUMN bolt11 TEXT")
|
||||
db.execute("ALTER TABLE apipayments ADD COLUMN extra TEXT")
|
||||
|
||||
import json
|
||||
|
||||
rows = db.fetchall("SELECT * FROM apipayments")
|
||||
for row in rows:
|
||||
if not row["memo"] or not row["memo"].startswith("#"):
|
||||
continue
|
||||
|
||||
for ext in ["withdraw", "events", "lnticket", "paywall", "tpos"]:
|
||||
prefix = "#" + ext + " "
|
||||
if row["memo"].startswith(prefix):
|
||||
new = row["memo"][len(prefix) :]
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?, memo = ?
|
||||
WHERE checking_id = ? AND memo = ?
|
||||
""",
|
||||
(json.dumps({"tag": ext}), new, row["checking_id"], row["memo"]),
|
||||
)
|
||||
break
|
||||
except sqlite3.OperationalError:
|
||||
# this is necessary now because it may be the case that this migration will
|
||||
# run twice in some environments.
|
||||
# catching errors like this won't be necessary in anymore now that we
|
||||
# keep track of db versions so no migration ever runs twice.
|
||||
pass
|
||||
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
from typing import List, NamedTuple, Optional, Dict
|
||||
from sqlite3 import Row
|
||||
|
||||
|
||||
class User(NamedTuple):
|
||||
id: str
|
||||
email: str
|
||||
extensions: List[str] = []
|
||||
wallets: List["Wallet"] = []
|
||||
password: Optional[str] = None
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> List[str]:
|
||||
return [wallet.id for wallet in self.wallets]
|
||||
|
||||
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
|
||||
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
|
||||
return w[0] if w else None
|
||||
|
||||
|
||||
class Wallet(NamedTuple):
|
||||
id: str
|
||||
name: str
|
||||
user: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
balance_msat: int
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
return self.balance_msat // 1000
|
||||
|
||||
def get_payment(self, payment_hash: str) -> Optional["Payment"]:
|
||||
from .crud import get_wallet_payment
|
||||
|
||||
return get_wallet_payment(self.id, payment_hash)
|
||||
|
||||
def get_payments(
|
||||
self,
|
||||
*,
|
||||
complete: bool = True,
|
||||
pending: bool = False,
|
||||
outgoing: bool = True,
|
||||
incoming: bool = True,
|
||||
exclude_uncheckable: bool = False
|
||||
) -> List["Payment"]:
|
||||
from .crud import get_wallet_payments
|
||||
|
||||
return get_wallet_payments(
|
||||
self.id,
|
||||
complete=complete,
|
||||
pending=pending,
|
||||
outgoing=outgoing,
|
||||
incoming=incoming,
|
||||
exclude_uncheckable=exclude_uncheckable,
|
||||
)
|
||||
|
||||
|
||||
class Payment(NamedTuple):
|
||||
checking_id: str
|
||||
pending: bool
|
||||
amount: int
|
||||
fee: int
|
||||
memo: str
|
||||
time: int
|
||||
bolt11: str
|
||||
preimage: str
|
||||
payment_hash: str
|
||||
extra: Dict
|
||||
|
||||
@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"],
|
||||
)
|
||||
|
||||
@property
|
||||
def tag(self) -> Optional[str]:
|
||||
return self.extra.get("tag")
|
||||
|
||||
@property
|
||||
def msat(self) -> int:
|
||||
return self.amount
|
||||
|
||||
@property
|
||||
def sat(self) -> int:
|
||||
return self.amount // 1000
|
||||
|
||||
@property
|
||||
def is_in(self) -> bool:
|
||||
return self.amount > 0
|
||||
|
||||
@property
|
||||
def is_out(self) -> bool:
|
||||
return self.amount < 0
|
||||
|
||||
@property
|
||||
def is_uncheckable(self) -> bool:
|
||||
return self.checking_id.startswith("temp_") or self.checking_id.startswith("internal_")
|
||||
|
||||
def set_pending(self, pending: bool) -> None:
|
||||
from .crud import update_payment_status
|
||||
|
||||
update_payment_status(self.checking_id, pending)
|
||||
|
||||
def delete(self) -> None:
|
||||
from .crud import delete_payment
|
||||
|
||||
delete_payment(self.checking_id)
|
||||
@@ -0,0 +1,127 @@
|
||||
from typing import Optional, Tuple, Dict
|
||||
from quart import g
|
||||
|
||||
try:
|
||||
from typing import TypedDict # type: ignore
|
||||
except ImportError: # pragma: nocover
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.settings import WALLET
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
|
||||
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,
|
||||
description_hash: Optional[bytes] = None,
|
||||
extra: Optional[Dict] = None,
|
||||
) -> Tuple[str, str]:
|
||||
invoice_memo = None if description_hash else memo
|
||||
storeable_memo = memo
|
||||
|
||||
ok, checking_id, payment_request, error_message = WALLET.create_invoice(
|
||||
amount=amount, memo=invoice_memo, description_hash=description_hash
|
||||
)
|
||||
if not ok:
|
||||
raise Exception(error_message or "Unexpected backend error.")
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
|
||||
amount_msat = amount * 1000
|
||||
create_payment(
|
||||
wallet_id=wallet_id,
|
||||
checking_id=checking_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=amount_msat,
|
||||
memo=storeable_memo,
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
g.db.commit()
|
||||
return invoice.payment_hash, payment_request
|
||||
|
||||
|
||||
def pay_invoice(
|
||||
*, wallet_id: str, payment_request: str, max_sat: Optional[int] = None, extra: Optional[Dict] = None
|
||||
) -> str:
|
||||
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.")
|
||||
|
||||
# put all parameters that don't change here
|
||||
PaymentKwargs = TypedDict(
|
||||
"PaymentKwargs",
|
||||
{
|
||||
"wallet_id": str,
|
||||
"payment_request": str,
|
||||
"payment_hash": str,
|
||||
"amount": int,
|
||||
"memo": str,
|
||||
"extra": Optional[Dict],
|
||||
},
|
||||
)
|
||||
payment_kwargs: PaymentKwargs = dict(
|
||||
wallet_id=wallet_id,
|
||||
payment_request=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount=-invoice.amount_msat,
|
||||
memo=invoice.description or "",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
# check_internal() returns the checking_id of the invoice we're waiting for
|
||||
internal = check_internal(invoice.payment_hash)
|
||||
if internal:
|
||||
# create a new payment from this wallet
|
||||
create_payment(checking_id=internal_id, fee=0, pending=False, **payment_kwargs)
|
||||
else:
|
||||
# create a temporary payment here so we can check if
|
||||
# the balance is enough in the next step
|
||||
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
|
||||
create_payment(checking_id=temp_id, fee=-fee_reserve, **payment_kwargs)
|
||||
|
||||
# do the balance check
|
||||
wallet = get_wallet(wallet_id)
|
||||
assert wallet, "invalid wallet id"
|
||||
if wallet.balance_msat < 0:
|
||||
g.db.rollback()
|
||||
raise PermissionError("Insufficient balance.")
|
||||
else:
|
||||
g.db.commit()
|
||||
|
||||
if internal:
|
||||
# mark the invoice from the other side as not pending anymore
|
||||
# so the other side only has access to his new money when we are sure
|
||||
# the payer has enough to deduct from
|
||||
update_payment_status(checking_id=internal, pending=False)
|
||||
else:
|
||||
# actually pay the external invoice
|
||||
ok, checking_id, fee_msat, error_message = WALLET.pay_invoice(payment_request)
|
||||
if ok:
|
||||
create_payment(checking_id=checking_id, fee=fee_msat, **payment_kwargs)
|
||||
delete_payment(temp_id)
|
||||
else:
|
||||
raise Exception(error_message or "Failed to pay_invoice on backend.")
|
||||
|
||||
g.db.commit()
|
||||
return invoice.payment_hash
|
||||
|
||||
|
||||
def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
|
||||
payment = get_wallet_payment(wallet_id, payment_hash)
|
||||
if not payment:
|
||||
return PaymentStatus(None)
|
||||
|
||||
return WALLET.get_invoice_status(payment.checking_id)
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,4 @@
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
disclaimerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
walletName: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createWallet: function () {
|
||||
LNbits.href.createWallet(this.walletName)
|
||||
},
|
||||
processing: function () {
|
||||
this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Processing...',
|
||||
icon: null
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,423 @@
|
||||
/* globals decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
|
||||
function generateChart(canvas, payments) {
|
||||
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(_.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)
|
||||
})
|
||||
|
||||
new Chart(canvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: data.cumulative,
|
||||
type: 'line',
|
||||
label: 'balance',
|
||||
backgroundColor: '#673ab7', // deep-purple
|
||||
borderColor: '#673ab7',
|
||||
borderWidth: 4,
|
||||
pointRadius: 3,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
data: data.income,
|
||||
type: 'bar',
|
||||
label: 'in',
|
||||
barPercentage: 0.75,
|
||||
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
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
title: {
|
||||
text: 'Chart.js Combo Time Scale'
|
||||
},
|
||||
tooltips: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
type: 'time',
|
||||
display: true,
|
||||
offset: true,
|
||||
time: {
|
||||
minUnit: 'hour',
|
||||
stepSize: 3
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
// performance tweaks
|
||||
animation: {
|
||||
duration: 0
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
user: LNbits.map.user(window.user),
|
||||
receive: {
|
||||
show: false,
|
||||
status: 'pending',
|
||||
paymentReq: null,
|
||||
data: {
|
||||
amount: null,
|
||||
memo: ''
|
||||
}
|
||||
},
|
||||
send: {
|
||||
show: false,
|
||||
invoice: null,
|
||||
data: {
|
||||
bolt11: ''
|
||||
}
|
||||
},
|
||||
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
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
},
|
||||
filter: null
|
||||
},
|
||||
paymentsChart: {
|
||||
show: false
|
||||
},
|
||||
disclaimerDialog: {
|
||||
show: false,
|
||||
location: window.location
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredPayments: function () {
|
||||
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)
|
||||
},
|
||||
canPay: function () {
|
||||
if (!this.send.invoice) return false
|
||||
return this.send.invoice.sat <= this.balance
|
||||
},
|
||||
pendingPaymentsExist: function () {
|
||||
return this.payments
|
||||
? _.where(this.payments, {pending: 1}).length > 0
|
||||
: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeCamera: function () {
|
||||
this.sendCamera.show = false
|
||||
},
|
||||
showCamera: function () {
|
||||
this.sendCamera.show = true
|
||||
},
|
||||
showChart: function () {
|
||||
this.paymentsChart.show = true
|
||||
this.$nextTick(function () {
|
||||
generateChart(this.$refs.canvas, this.payments)
|
||||
})
|
||||
},
|
||||
showReceiveDialog: function () {
|
||||
this.receive = {
|
||||
show: true,
|
||||
status: 'pending',
|
||||
paymentReq: null,
|
||||
data: {
|
||||
amount: null,
|
||||
memo: ''
|
||||
},
|
||||
paymentChecker: null
|
||||
}
|
||||
},
|
||||
showSendDialog: function () {
|
||||
this.send = {
|
||||
show: true,
|
||||
invoice: null,
|
||||
data: {
|
||||
bolt11: ''
|
||||
},
|
||||
paymentChecker: null
|
||||
}
|
||||
},
|
||||
closeReceiveDialog: function () {
|
||||
var checker = this.receive.paymentChecker
|
||||
setTimeout(function () {
|
||||
clearInterval(checker)
|
||||
}, 10000)
|
||||
},
|
||||
closeSendDialog: function () {
|
||||
this.sendCamera.show = false
|
||||
var checker = this.send.paymentChecker
|
||||
setTimeout(function () {
|
||||
clearInterval(checker)
|
||||
}, 1000)
|
||||
},
|
||||
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
|
||||
|
||||
self.receive.paymentChecker = setInterval(function () {
|
||||
LNbits.api
|
||||
.getPayment(self.g.wallet, response.data.payment_hash)
|
||||
.then(function (response) {
|
||||
if (response.data.paid) {
|
||||
self.fetchPayments()
|
||||
self.receive.show = false
|
||||
clearInterval(self.receive.paymentChecker)
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
self.receive.status = 'pending'
|
||||
})
|
||||
},
|
||||
decodeQR: function (res) {
|
||||
this.send.data.bolt11 = res
|
||||
this.decodeInvoice()
|
||||
this.sendCamera.show = false
|
||||
},
|
||||
decodeInvoice: function () {
|
||||
if (this.send.data.bolt11.startsWith('lightning:')) {
|
||||
this.send.data.bolt11 = this.send.data.bolt11.slice(10)
|
||||
}
|
||||
|
||||
let invoice
|
||||
try {
|
||||
invoice = decode(this.send.data.bolt11)
|
||||
} catch (error) {
|
||||
this.$q.notify({
|
||||
timeout: 3000,
|
||||
type: 'warning',
|
||||
message: error + '.',
|
||||
caption: '400 BAD REQUEST',
|
||||
icon: null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.send.invoice = Object.freeze(cleanInvoice)
|
||||
},
|
||||
payInvoice: function () {
|
||||
var self = this
|
||||
|
||||
let dismissPaymentMsg = this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Processing payment...',
|
||||
icon: null
|
||||
})
|
||||
|
||||
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.payment_hash)
|
||||
.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)
|
||||
})
|
||||
},
|
||||
deleteWallet: function (walletId, user) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this wallet?')
|
||||
.onOk(function () {
|
||||
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
|
||||
})
|
||||
})
|
||||
},
|
||||
checkPendingPayments: function () {
|
||||
var dismissMsg = this.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Checking pending transactions...',
|
||||
icon: null
|
||||
})
|
||||
|
||||
this.fetchPayments(true).then(function () {
|
||||
dismissMsg()
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
payments: function () {
|
||||
EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance])
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.fetchPayments()
|
||||
setTimeout(this.checkPendingPayments(), 1200)
|
||||
},
|
||||
mounted: function () {
|
||||
if (
|
||||
this.$refs.disclaimer &&
|
||||
!this.$q.localStorage.getItem('lnbits.disclaimerShown')
|
||||
) {
|
||||
this.disclaimerDialog.show = true
|
||||
this.$q.localStorage.set('lnbits.disclaimerShown', true)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
<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="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
|
||||
>{"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
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<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>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<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>{"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:
|
||||
<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-card>
|
||||
<q-card-section>
|
||||
<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>
|
||||
<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/<payment_hash> -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>
|
||||
@@ -0,0 +1,54 @@
|
||||
{% 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="[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 %}
|
||||
@@ -0,0 +1,110 @@
|
||||
{% 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>
|
||||
|
||||
<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 %}
|
||||
@@ -0,0 +1,403 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block scripts %} {{ window_vars(user, wallet) }}
|
||||
<script src="/core/static/js/wallet.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>
|
||||
<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>
|
||||
</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-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>
|
||||
</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="lnbits__q-table__icon-td">
|
||||
<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">
|
||||
<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">
|
||||
{{ props.row.date }}
|
||||
</q-td>
|
||||
<q-td auto-width key="sat" :props="props">
|
||||
{{ props.row.fsat }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
|
||||
<q-dialog v-model="props.expand" :props="props">
|
||||
<q-card
|
||||
v-if="props.row.amount > 0 && props.row.pending"
|
||||
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-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<div class="text-center q-mb-lg">
|
||||
<div v-if="props.row.isPaid && props.row.isIn">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_received'"
|
||||
:color="'green'"
|
||||
></q-icon>
|
||||
Payment Received
|
||||
</div>
|
||||
<div v-else-if="props.row.isPaid && props.row.isOut">
|
||||
<q-icon
|
||||
size="18px"
|
||||
:name="'call_made'"
|
||||
:color="'pink'"
|
||||
></q-icon>
|
||||
Payment Sent
|
||||
</div>
|
||||
<div v-else>
|
||||
<q-icon name="settings_ethernet" color="grey"></q-icon>
|
||||
Outgoing payment pending
|
||||
</div>
|
||||
<q-tooltip>Payment Hash</q-tooltip>
|
||||
<div class="text-wrap mono q-pa-md">
|
||||
{{ props.row.payment_hash }}
|
||||
</div>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</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>
|
||||
<q-btn flat color="grey" @click="exportCSV" class="float-right"
|
||||
>Renew keys</q-btn
|
||||
>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">LNbits wallet</h6>
|
||||
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br />
|
||||
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
|
||||
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
|
||||
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "core/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="remove_circle"
|
||||
label="Delete wallet"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
This whole wallet will be deleted, the funds will be
|
||||
<strong>UNRECOVERABLE</strong>.
|
||||
</p>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="red-10"
|
||||
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
|
||||
>Delete wallet</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-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 %}
|
||||
@@ -1,98 +0,0 @@
|
||||
<!-- @format -->
|
||||
|
||||
{% extends "base.html" %} {% block menuitems %}
|
||||
|
||||
<li><a href="https://where39.com/"><p>Where39 anon locations</p><img src="static/where39.png" style="width:170px"></a></li>
|
||||
<li><a href="https://github.com/arcbtc/Quickening"><p>The Quickening <$8 PoS</p><img src="static/quick.gif" style="width:170px"></a></li>
|
||||
<li><a href="http://jigawatt.co/"><p>Buy BTC stamps + electronics</p><img src="static/stamps.jpg" style="width:170px"></a></li>
|
||||
<li><a href="mailto:ben@arc.wales"><h3>Advertise here!</h3></a></li>
|
||||
{% endblock %} {% block body %}
|
||||
<!-- Right side column. Contains the navbar and content of the page -->
|
||||
<div class="content-wrapper">
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<ol class="breadcrumb">
|
||||
<li>
|
||||
<a href="{{ url_for('core.home') }}"><i class="fa fa-dashboard"></i> Home</a>
|
||||
</li>
|
||||
</ol>
|
||||
<br /><br />
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-danger alert-dismissable">
|
||||
<h4>
|
||||
TESTING ONLY - wallet is still in BETA and very unstable
|
||||
</h4>
|
||||
</div></div></div>
|
||||
</section>
|
||||
|
||||
<!-- Main content -->
|
||||
<section class="content">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<!-- Default box -->
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
{% block call_to_action %}
|
||||
<h1>
|
||||
<small>Make a wallet</small>
|
||||
</h1>
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="walname"
|
||||
placeholder="Name your LNBits wallet"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" onclick="newwallet()">
|
||||
Submit
|
||||
</button>
|
||||
{% endblock %}
|
||||
</div>
|
||||
<!-- /.box-body -->
|
||||
</div>
|
||||
<!-- /.box -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<!-- Default box -->
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h1>
|
||||
<a href="index.html" class="logo"><b>LN</b>bits</a>
|
||||
<small>free and open-source lightning wallet</small>
|
||||
</h1>
|
||||
<p>
|
||||
LNbits is a simple, free and open-source lightning-network wallet
|
||||
for bits and bobs. You can run it on your own server, or use this
|
||||
one.
|
||||
<br /><br />
|
||||
The wallet can be used in a variety of ways, an instant wallet for
|
||||
LN demonstrations, a fallback wallet for the LNURL scheme, an
|
||||
accounts system to mitigate the risk of exposing applications to
|
||||
your full balance.
|
||||
<br /><br />
|
||||
The wallet can run on top of LND, lntxbot, paywall, opennode
|
||||
<br /><br />
|
||||
Please note that although one of the aims of this wallet is to
|
||||
mitigate exposure of all your funds, it’s still very BETA and may
|
||||
in fact do the opposite!
|
||||
<br />
|
||||
<a href="https://github.com/arcbtc/lnbits"
|
||||
>https://github.com/arcbtc/lnbits</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<!-- /.box-body -->
|
||||
</div>
|
||||
<!-- /.box -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- /.content -->
|
||||
</div>
|
||||
<!-- /.content-wrapper -->
|
||||
{% endblock %}
|
||||
@@ -1,14 +0,0 @@
|
||||
from flask import render_template, send_from_directory
|
||||
from os import path
|
||||
|
||||
from lnbits.core import core_app
|
||||
|
||||
|
||||
@core_app.route("/favicon.ico")
|
||||
def favicon():
|
||||
return send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
|
||||
|
||||
|
||||
@core_app.route("/")
|
||||
def home():
|
||||
return render_template("index.html")
|
||||
@@ -0,0 +1,124 @@
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
from binascii import unhexlify
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core import core_app
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.core.crud import delete_expired_invoices
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
|
||||
@core_app.route("/api/v1/payments", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_payments():
|
||||
if "check_pending" in request.args:
|
||||
delete_expired_invoices()
|
||||
|
||||
for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=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()), 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, "excludes": "description_hash"},
|
||||
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
|
||||
}
|
||||
)
|
||||
async def api_payments_create_invoice():
|
||||
if "description_hash" in g.data:
|
||||
description_hash = unhexlify(g.data["description_hash"])
|
||||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
memo = g.data["memo"]
|
||||
|
||||
try:
|
||||
payment_hash, payment_request = create_invoice(
|
||||
wallet_id=g.wallet.id, amount=g.data["amount"], memo=memo, description_hash=description_hash
|
||||
)
|
||||
except Exception as e:
|
||||
g.db.rollback()
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
invoice = bolt11.decode(payment_request)
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"payment_hash": invoice.payment_hash,
|
||||
"payment_request": payment_request,
|
||||
# maintain backwards compatibility with API clients:
|
||||
"checking_id": invoice.payment_hash,
|
||||
}
|
||||
),
|
||||
HTTPStatus.CREATED,
|
||||
)
|
||||
|
||||
|
||||
@api_check_wallet_key("admin")
|
||||
@api_validate_post_request(schema={"bolt11": {"type": "string", "empty": False, "required": True}})
|
||||
async def api_payments_pay_invoice():
|
||||
try:
|
||||
payment_hash = pay_invoice(wallet_id=g.wallet.id, payment_request=g.data["bolt11"])
|
||||
except ValueError as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
|
||||
except PermissionError as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
|
||||
except Exception as e:
|
||||
print(e)
|
||||
g.db.rollback()
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
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}})
|
||||
async def api_payments_create():
|
||||
if g.data["out"] is True:
|
||||
return await api_payments_pay_invoice()
|
||||
return await api_payments_create_invoice()
|
||||
|
||||
|
||||
@core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_payment(payment_hash):
|
||||
payment = g.wallet.get_payment(payment_hash)
|
||||
|
||||
if not payment:
|
||||
return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
|
||||
elif not payment.pending:
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
|
||||
try:
|
||||
if payment.is_uncheckable:
|
||||
pass
|
||||
elif payment.is_out:
|
||||
is_paid = not WALLET.get_payment_status(payment.checking_id).pending
|
||||
elif payment.is_in:
|
||||
is_paid = not WALLET.get_invoice_status(payment.checking_id).pending
|
||||
except Exception:
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
if is_paid:
|
||||
payment.set_pending(False)
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
@@ -0,0 +1,100 @@
|
||||
from quart import g, abort, redirect, request, render_template, send_from_directory, url_for
|
||||
from http import HTTPStatus
|
||||
from os import path
|
||||
|
||||
from lnbits.core import core_app
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
|
||||
|
||||
from ..crud import (
|
||||
create_account,
|
||||
get_user,
|
||||
update_user_extension,
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
)
|
||||
|
||||
|
||||
@core_app.route("/favicon.ico")
|
||||
async def favicon():
|
||||
return await send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
|
||||
|
||||
|
||||
@core_app.route("/")
|
||||
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()
|
||||
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(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)
|
||||
elif extension_to_disable:
|
||||
update_user_extension(user_id=g.user.id, extension=extension_to_disable, active=0)
|
||||
|
||||
return await render_template("core/extensions.html", user=get_user(g.user.id))
|
||||
|
||||
|
||||
@core_app.route("/wallet")
|
||||
@validate_uuids(["usr", "wal"])
|
||||
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)
|
||||
service_fee = int(SERVICE_FEE) if int(SERVICE_FEE) == SERVICE_FEE else SERVICE_FEE
|
||||
|
||||
# just wallet_name: create a new user, then create a new wallet for user with wallet_name
|
||||
# just user_id: return the first user wallet or create one if none found (with default wallet_name)
|
||||
# user_id and wallet_name: create a new wallet for user with wallet_name
|
||||
# user_id and wallet_id: return that wallet if user is the owner
|
||||
# nothing: create everything
|
||||
|
||||
if not user_id:
|
||||
user = get_user(create_account().id)
|
||||
else:
|
||||
user = get_user(user_id) or abort(HTTPStatus.NOT_FOUND, "User does not exist.")
|
||||
|
||||
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)
|
||||
|
||||
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
|
||||
|
||||
if wallet_id not in user.wallet_ids:
|
||||
abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
|
||||
return await render_template(
|
||||
"core/wallet.html", user=user, wallet=user.get_wallet(wallet_id), service_fee=service_fee
|
||||
)
|
||||
|
||||
|
||||
@core_app.route("/deletewallet")
|
||||
@validate_uuids(["usr", "wal"], required=True)
|
||||
@check_user_exists()
|
||||
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(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
else:
|
||||
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"))
|
||||
@@ -0,0 +1,66 @@
|
||||
import requests
|
||||
|
||||
from quart import abort, redirect, request, url_for
|
||||
from http import HTTPStatus
|
||||
from time import sleep
|
||||
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore
|
||||
from lnurl.exceptions import LnurlException # type: ignore
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core import core_app
|
||||
from lnbits.settings import WALLET
|
||||
|
||||
from ..crud import create_account, get_user, create_wallet, create_payment
|
||||
|
||||
|
||||
@core_app.route("/lnurlwallet")
|
||||
async def lnurlwallet():
|
||||
memo = "LNbits LNURL funding"
|
||||
|
||||
try:
|
||||
withdraw_res = handle_lnurl(request.args.get("lightning"))
|
||||
if not withdraw_res.ok:
|
||||
abort(HTTPStatus.BAD_REQUEST, f"Could not process LNURL-withdraw: {withdraw_res.error_msg}")
|
||||
if not isinstance(withdraw_res, LnurlWithdrawResponse):
|
||||
abort(HTTPStatus.BAD_REQUEST, "Not a valid LNURL-withdraw.")
|
||||
except LnurlException:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process LNURL-withdraw.")
|
||||
|
||||
try:
|
||||
ok, checking_id, payment_request, error_message = WALLET.create_invoice(withdraw_res.max_sats, memo)
|
||||
except Exception as e:
|
||||
ok, error_message = False, str(e)
|
||||
|
||||
if not ok:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, error_message)
|
||||
|
||||
r = requests.get(
|
||||
withdraw_res.callback.base,
|
||||
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
|
||||
)
|
||||
|
||||
if not r.ok:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process LNURL-withdraw.")
|
||||
|
||||
inv = bolt11.decode(payment_request)
|
||||
|
||||
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,
|
||||
payment_request=payment_request,
|
||||
payment_hash=inv.payment_hash,
|
||||
)
|
||||
|
||||
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -1,47 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT,
|
||||
pass TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS extensions (
|
||||
user TEXT NOT NULL,
|
||||
extension TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT 0,
|
||||
|
||||
UNIQUE (user, extension)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
user TEXT NOT NULL,
|
||||
adminkey TEXT NOT NULL,
|
||||
inkey TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS apipayments (
|
||||
payhash TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
fee INTEGER NOT NULL DEFAULT 0,
|
||||
wallet TEXT NOT NULL,
|
||||
pending BOOLEAN NOT NULL,
|
||||
memo TEXT,
|
||||
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
|
||||
UNIQUE (wallet, payhash)
|
||||
);
|
||||
|
||||
CREATE VIEW IF NOT EXISTS balances AS
|
||||
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
|
||||
SELECT wallet, SUM(amount) AS s -- incoming
|
||||
FROM apipayments
|
||||
WHERE amount > 0 AND pending = 0 -- don't sum pending
|
||||
GROUP BY wallet
|
||||
UNION ALL
|
||||
SELECT wallet, SUM(amount + fee) AS s -- outgoing, sum fees
|
||||
FROM apipayments
|
||||
WHERE amount < 0 -- do sum pending
|
||||
GROUP BY wallet
|
||||
)
|
||||
GROUP BY wallet;
|
||||
@@ -1,8 +1,7 @@
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
from .helpers import ExtensionManager
|
||||
from .settings import LNBITS_PATH, LNBITS_DATA_FOLDER
|
||||
from .settings import LNBITS_DATA_FOLDER
|
||||
|
||||
|
||||
class Database:
|
||||
@@ -16,22 +15,37 @@ class Database:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.cursor.close()
|
||||
self.connection.close()
|
||||
if exc_val:
|
||||
self.connection.rollback()
|
||||
self.cursor.close()
|
||||
self.cursor.close()
|
||||
else:
|
||||
self.connection.commit()
|
||||
self.cursor.close()
|
||||
self.connection.close()
|
||||
|
||||
def fetchall(self, query: str, values: tuple) -> list:
|
||||
def commit(self):
|
||||
self.connection.commit()
|
||||
|
||||
def rollback(self):
|
||||
self.connection.rollback()
|
||||
|
||||
def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||
"""Given a query, return cursor.fetchall() rows."""
|
||||
self.cursor.execute(query, values)
|
||||
self.execute(query, values)
|
||||
return self.cursor.fetchall()
|
||||
|
||||
def fetchone(self, query: str, values: tuple):
|
||||
self.cursor.execute(query, values)
|
||||
def fetchone(self, query: str, values: tuple = ()):
|
||||
self.execute(query, values)
|
||||
return self.cursor.fetchone()
|
||||
|
||||
def execute(self, query: str, values: tuple) -> None:
|
||||
def execute(self, query: str, values: tuple = ()) -> None:
|
||||
"""Given a query, cursor.execute() it."""
|
||||
self.cursor.execute(query, values)
|
||||
self.connection.commit()
|
||||
try:
|
||||
self.cursor.execute(query, values)
|
||||
except sqlite3.Error as exc:
|
||||
self.connection.rollback()
|
||||
raise exc
|
||||
|
||||
|
||||
def open_db(db_name: str = "database") -> Database:
|
||||
@@ -41,21 +55,3 @@ def open_db(db_name: str = "database") -> Database:
|
||||
|
||||
def open_ext_db(extension_name: str) -> Database:
|
||||
return open_db(f"ext_{extension_name}")
|
||||
|
||||
|
||||
def init_databases() -> None:
|
||||
"""Creates the necessary databases if they don't exist already."""
|
||||
"""TODO: see how we can deal with migrations."""
|
||||
|
||||
schemas = [
|
||||
("database", os.path.join(LNBITS_PATH, "data", "schema.sql")),
|
||||
]
|
||||
|
||||
for extension in ExtensionManager().extensions:
|
||||
schemas.append((f"ext_{extension.code}", os.path.join(extension.path, "schema.sql")))
|
||||
|
||||
for schema in [s for s in schemas if os.path.exists(s[1])]:
|
||||
with open_db(schema[0]) as db:
|
||||
with open(schema[1]) as schemafile:
|
||||
for stmt in schemafile.read().split(";\n\n"):
|
||||
db.execute(stmt, [])
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
from cerberus import Validator # type: ignore
|
||||
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 lnbits.settings import LNBITS_ALLOWED_USERS
|
||||
|
||||
|
||||
def api_check_wallet_key(key_type: str = "invoice"):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
async def wrapped_view(**kwargs):
|
||||
try:
|
||||
g.wallet = get_wallet_for_key(request.headers["X-Api-Key"], key_type)
|
||||
except KeyError:
|
||||
return (
|
||||
jsonify({"message": "`X-Api-Key` header missing."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not g.wallet:
|
||||
return jsonify({"message": "Wrong keys."}), HTTPStatus.UNAUTHORIZED
|
||||
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def api_validate_post_request(*, schema: dict):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
async def wrapped_view(**kwargs):
|
||||
if "application/json" not in request.headers["Content-Type"]:
|
||||
return (
|
||||
jsonify({"message": "Content-Type must be `application/json`."}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
v = Validator(schema)
|
||||
data = await request.get_json()
|
||||
g.data = {key: data[key] for key in schema.keys() if key in data}
|
||||
|
||||
if not v.validate(g.data):
|
||||
return (
|
||||
jsonify({"message": f"Errors in request data: {v.errors}"}),
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
def check_user_exists(param: str = "usr"):
|
||||
def wrap(view):
|
||||
@wraps(view)
|
||||
async def wrapped_view(**kwargs):
|
||||
g.user = 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 wrap(view):
|
||||
@wraps(view)
|
||||
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(HTTPStatus.BAD_REQUEST, f"`{param}` is required.")
|
||||
|
||||
if value:
|
||||
try:
|
||||
UUID(value, version=version)
|
||||
except ValueError:
|
||||
abort(HTTPStatus.BAD_REQUEST, f"`{param}` is not a valid UUID.")
|
||||
|
||||
return await view(**kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
return wrap
|
||||
@@ -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,8 @@
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
amilk_ext: Blueprint = Blueprint("amilk", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "AMilk",
|
||||
"short_description": "Assistant Faucet Milker",
|
||||
"icon": "room_service",
|
||||
"contributors": ["arcbtc"]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.db import open_ext_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),
|
||||
)
|
||||
|
||||
return get_amilk(amilk_id)
|
||||
|
||||
|
||||
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,))
|
||||
|
||||
return AMilk(**row) if row else None
|
||||
|
||||
|
||||
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,))
|
||||
|
||||
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,))
|
||||
@@ -0,0 +1,15 @@
|
||||
def m001_initial(db):
|
||||
"""
|
||||
Initial amilks table.
|
||||
"""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS amilks (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
lnurl TEXT NOT NULL,
|
||||
atime INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class AMilk(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
lnurl: str
|
||||
atime: int
|
||||
amount: int
|
||||
@@ -0,0 +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-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,250 @@
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
{% 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
|
||||
}
|
||||
},
|
||||
amilkDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getAMilks: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/amilk/api/v1/amilk?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.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)
|
||||
}
|
||||
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)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/amilk/api/v1/amilk',
|
||||
_.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)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getAMilks()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
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 .crud import get_amilk
|
||||
|
||||
|
||||
@amilk_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("amilk/index.html", user=g.user)
|
||||
|
||||
|
||||
@amilk_ext.route("/<amilk_id>")
|
||||
async def wall(amilk_id):
|
||||
amilk = get_amilk(amilk_id) or abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.")
|
||||
return await render_template("amilk/wall.html", amilk=amilk)
|
||||
@@ -0,0 +1,88 @@
|
||||
import requests
|
||||
from quart import g, jsonify, request, abort
|
||||
from http import HTTPStatus
|
||||
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl
|
||||
from lnurl.exceptions import LnurlException
|
||||
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.core.services import create_invoice, check_invoice_status
|
||||
|
||||
from lnbits.extensions.amilk import amilk_ext
|
||||
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_amilks():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return jsonify([amilk._asdict() for amilk in get_amilks(wallet_ids)]), HTTPStatus.OK
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
|
||||
async def api_amilkit(amilk_id):
|
||||
milk = get_amilk(amilk_id)
|
||||
memo = milk.id
|
||||
|
||||
try:
|
||||
withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse)
|
||||
except LnurlException:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
|
||||
payment_hash, payment_request = create_invoice(
|
||||
wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo, extra={"tag": "amilk"}
|
||||
)
|
||||
|
||||
r = requests.get(
|
||||
withdraw_res.callback.base,
|
||||
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
|
||||
)
|
||||
|
||||
if not r.ok:
|
||||
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
|
||||
|
||||
for i in range(10):
|
||||
sleep(i)
|
||||
invoice_status = check_invoice_status(milk.wallet, payment_hash)
|
||||
if invoice_status.paid:
|
||||
return jsonify({"paid": True}), HTTPStatus.OK
|
||||
else:
|
||||
continue
|
||||
|
||||
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},
|
||||
}
|
||||
)
|
||||
async def api_amilk_create():
|
||||
amilk = create_amilk(wallet_id=g.wallet.id, lnurl=g.data["lnurl"], atime=g.data["atime"], amount=g.data["amount"])
|
||||
|
||||
return jsonify(amilk._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_amilk_delete(amilk_id):
|
||||
amilk = get_amilk(amilk_id)
|
||||
|
||||
if not amilk:
|
||||
return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if amilk.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your amilk."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_amilk(amilk_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
@@ -0,0 +1,10 @@
|
||||
<h1>Diagon Alley</h1>
|
||||
<h2>A movable market stand</h2>
|
||||
Make a list of products to sell, point the list to an indexer (or many), stack sats.
|
||||
Diagon Alley is a movable market stand, for anon transactions. You then give permission for an indexer to list those products. Delivery addresses are sent through the Lightning Network.
|
||||
<img src="https://i.imgur.com/P1tvBSG.png">
|
||||
|
||||
|
||||
<h2>API endpoints</h2>
|
||||
|
||||
<code>curl -X GET http://YOUR-TOR-ADDRESS</code>
|
||||
@@ -0,0 +1,8 @@
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
from .views import * # noqa
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Diagon Alley",
|
||||
"short_description": "Movable anonymous market stand",
|
||||
"icon": "add_shopping_cart",
|
||||
"contributors": ["benarc"]
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from uuid import uuid4
|
||||
from typing import List, Optional, Union
|
||||
import requests
|
||||
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,
|
||||
)
|
||||
|
||||
###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")
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(product_id, wallet_id, product, categories, description, image, price, quantity),
|
||||
)
|
||||
|
||||
return get_diagonalleys_product(product_id)
|
||||
|
||||
|
||||
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))
|
||||
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
|
||||
|
||||
return get_diagonalleys_indexer(product_id)
|
||||
|
||||
|
||||
def get_diagonalleys_product(product_id: str) -> Optional[Products]:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
|
||||
|
||||
return Products(**row) if row else None
|
||||
|
||||
|
||||
def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Products]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
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,))
|
||||
|
||||
return [Products(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_diagonalleys_product(product_id: str) -> None:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
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:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
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,
|
||||
),
|
||||
)
|
||||
return get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
|
||||
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))
|
||||
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
|
||||
|
||||
return get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
|
||||
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"])
|
||||
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,
|
||||
),
|
||||
)
|
||||
else:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
False,
|
||||
indexer_id,
|
||||
),
|
||||
)
|
||||
except:
|
||||
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
|
||||
|
||||
|
||||
def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexers]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
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,))
|
||||
|
||||
for r in rows:
|
||||
try:
|
||||
x = requests.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"],
|
||||
),
|
||||
)
|
||||
else:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE indexers SET online = ? WHERE id = ?",
|
||||
(
|
||||
False,
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
except:
|
||||
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,))
|
||||
return [Indexers(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_diagonalleys_indexer(indexer_id: str) -> None:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
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:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
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),
|
||||
)
|
||||
|
||||
return get_diagonalleys_order(order_id)
|
||||
|
||||
|
||||
def get_diagonalleys_order(order_id: str) -> Optional[Orders]:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
row = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
|
||||
|
||||
return Orders(**row) if row else None
|
||||
|
||||
|
||||
def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
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,))
|
||||
for r in rows:
|
||||
PAID = WALLET.get_invoice_status(r["invoiceid"]).paid
|
||||
if PAID:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute(
|
||||
"UPDATE orders SET paid = ? WHERE id = ?",
|
||||
(
|
||||
True,
|
||||
r["id"],
|
||||
),
|
||||
)
|
||||
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
return [Orders(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_diagonalleys_order(order_id: str) -> None:
|
||||
with open_ext_db("diagonalley") as db:
|
||||
db.execute("DELETE FROM orders WHERE id = ?", (order_id,))
|
||||
@@ -0,0 +1,60 @@
|
||||
def m001_initial(db):
|
||||
"""
|
||||
Initial products table.
|
||||
"""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
categories TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image TEXT NOT NULL,
|
||||
price INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial indexers table.
|
||||
"""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS indexers (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
shopname TEXT NOT NULL,
|
||||
indexeraddress TEXT NOT NULL,
|
||||
online BOOLEAN NOT NULL,
|
||||
rating INTEGER NOT NULL,
|
||||
shippingzone1 TEXT NOT NULL,
|
||||
shippingzone2 TEXT NOT NULL,
|
||||
zone1cost INTEGER NOT NULL,
|
||||
zone2cost INTEGER NOT NULL,
|
||||
email TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial orders table.
|
||||
"""
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
productid TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
shippingzone INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
invoiceid TEXT NOT NULL,
|
||||
paid BOOLEAN NOT NULL,
|
||||
shipped BOOLEAN NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class Indexers(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
shopname: str
|
||||
indexeraddress: str
|
||||
online: bool
|
||||
rating: str
|
||||
shippingzone1: str
|
||||
shippingzone2: str
|
||||
zone1cost: int
|
||||
zone2cost: int
|
||||
email: str
|
||||
|
||||
|
||||
class Products(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
product: str
|
||||
categories: str
|
||||
description: str
|
||||
image: str
|
||||
price: int
|
||||
quantity: int
|
||||
|
||||
|
||||
class Orders(NamedTuple):
|
||||
id: str
|
||||
productid: str
|
||||
wallet: str
|
||||
product: str
|
||||
quantity: int
|
||||
shippingzone: int
|
||||
address: str
|
||||
email: str
|
||||
invoiceid: str
|
||||
paid: bool
|
||||
shipped: bool
|
||||
@@ -0,0 +1,122 @@
|
||||
<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"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<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
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>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
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<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>
|
||||
</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-card>
|
||||
<q-card-section>
|
||||
<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>
|
||||
<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
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,906 @@
|
||||
{% 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="productDialog.show = true"
|
||||
>New Product</q-btn
|
||||
>
|
||||
<q-btn unelevated color="deep-purple" @click="indexerDialog.show = true"
|
||||
>New Indexer
|
||||
<q-tooltip>
|
||||
Frontend shop your stall will list its products in
|
||||
</q-tooltip></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">Products</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportProductsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="products"
|
||||
row-key="id"
|
||||
:columns="productsTable.columns"
|
||||
:pagination.sync="productsTable.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="add_shopping_cart"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="props.row.wallet"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-tooltip> Link to pass to stall indexer </q-tooltip>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openProductUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteProduct(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Indexers</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportIndexersCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="indexers"
|
||||
row-key="id"
|
||||
:columns="indexersTable.columns"
|
||||
:pagination.sync="indexersTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openIndexerUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteIndexer(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Orders</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportOrdersCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="orders"
|
||||
row-key="id"
|
||||
:columns="ordersTable.columns"
|
||||
:pagination.sync="ordersTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="shipOrder(props.row.id)"
|
||||
icon="add_shopping_cart"
|
||||
color="green"
|
||||
>
|
||||
<q-tooltip> Product shipped? </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteOrder(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 Diagon Alley Extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "diagonalley/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="productDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendProductFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="productDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.product"
|
||||
label="Product"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.categories"
|
||||
placeholder="cakes, guns, wool, drugs"
|
||||
label="Categories seperated by comma"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.description"
|
||||
label="Description"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="productDialog.data.image"
|
||||
label="Image"
|
||||
placeholder="Imagur link (max 500/500px)"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.price"
|
||||
type="number"
|
||||
label="Price"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="productDialog.data.quantity"
|
||||
type="number"
|
||||
label="Quantity"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="productDialog.data.id"
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
type="submit"
|
||||
>Update Product</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="productDialog.data.image == null
|
||||
|| productDialog.data.product == null
|
||||
|| productDialog.data.description == null
|
||||
|| productDialog.data.quantity == null"
|
||||
type="submit"
|
||||
>Create Product</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="indexerDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="sendIndexerFormData" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="indexerDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Wallet *"
|
||||
>
|
||||
</q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.shopname"
|
||||
label="Shop Name"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.indexeraddress"
|
||||
label="Shop link (LNbits will point to)"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.shippingzone1"
|
||||
multiple
|
||||
:options="shippingoptions"
|
||||
label="Shipping Zone 1"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="indexerDialog.data.zone1cost"
|
||||
type="number"
|
||||
label="Zone 1 Cost"
|
||||
></q-input>
|
||||
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.shippingzone2"
|
||||
multiple
|
||||
:options="shippingoptions"
|
||||
label="Shipping Zone 2"
|
||||
></q-select>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="indexerDialog.data.zone2cost"
|
||||
type="number"
|
||||
label="Zone 2 Cost"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="indexerDialog.data.email"
|
||||
label="Email to share with customers"
|
||||
></q-input>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="indexerDialog.data.id"
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
type="submit"
|
||||
>Update Indexer</q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="indexerDialog.data.shopname == null
|
||||
|| indexerDialog.data.shippingzone1 == null
|
||||
|| indexerDialog.data.indexeraddress == null
|
||||
|| indexerDialog.data.zone1cost == null
|
||||
|| indexerDialog.data.shippingzone2 == null
|
||||
|| indexerDialog.data.zone2cost == null
|
||||
|| indexerDialog.data.email == null"
|
||||
type="submit"
|
||||
>Create Indexer</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 %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapDiagonAlley = 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 = ['/diagonalley/', obj.id].join('')
|
||||
obj._data = _.clone(obj)
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
products: [],
|
||||
orders: [],
|
||||
indexers: [],
|
||||
shippedModel: false,
|
||||
shippingoptions: [
|
||||
'Australia',
|
||||
'Austria',
|
||||
'Belgium',
|
||||
'Brazil',
|
||||
'Canada',
|
||||
'Denmark',
|
||||
'Finland',
|
||||
'France*',
|
||||
'Germany',
|
||||
'Greece',
|
||||
'Hong Kong',
|
||||
'Hungary',
|
||||
'Ireland',
|
||||
'Indonesia',
|
||||
'Israel',
|
||||
'Italy',
|
||||
'Japan',
|
||||
'Kazakhstan',
|
||||
'Korea',
|
||||
'Luxembourg',
|
||||
'Malaysia',
|
||||
'Mexico',
|
||||
'Netherlands',
|
||||
'New Zealand',
|
||||
'Norway',
|
||||
'Poland',
|
||||
'Portugal',
|
||||
'Russia',
|
||||
'Saudi Arabia',
|
||||
'Singapore',
|
||||
'Spain',
|
||||
'Sweden',
|
||||
'Switzerland',
|
||||
'Thailand',
|
||||
'Turkey',
|
||||
'Ukraine',
|
||||
'United Kingdom**',
|
||||
'United States***',
|
||||
'Vietnam',
|
||||
'China'
|
||||
],
|
||||
label: '',
|
||||
indexersTable: {
|
||||
columns: [
|
||||
{name: 'shopname', align: 'left', label: 'Shop', field: 'shopname'},
|
||||
{
|
||||
name: 'indexeraddress',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'indexeraddress'
|
||||
},
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{
|
||||
name: 'rating',
|
||||
align: 'left',
|
||||
label: 'Your Rating',
|
||||
field: 'rating'
|
||||
},
|
||||
{name: 'email', align: 'left', label: 'Your email', field: 'email'},
|
||||
{name: 'online', align: 'left', label: 'Online', field: 'online'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
ordersTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'product',
|
||||
align: 'left',
|
||||
label: 'Product',
|
||||
field: 'product'
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
align: 'left',
|
||||
label: 'Quantity',
|
||||
field: 'quantity'
|
||||
},
|
||||
{
|
||||
name: 'address',
|
||||
align: 'left',
|
||||
label: 'Address',
|
||||
field: 'address'
|
||||
},
|
||||
{
|
||||
name: 'invoiceid',
|
||||
align: 'left',
|
||||
label: 'InvoiceID',
|
||||
field: 'invoiceid'
|
||||
},
|
||||
{name: 'paid', align: 'left', label: 'Paid', field: 'paid'},
|
||||
{name: 'shipped', align: 'left', label: 'Shipped', field: 'shipped'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
productsTable: {
|
||||
columns: [
|
||||
{
|
||||
name: 'product',
|
||||
align: 'left',
|
||||
label: 'Product',
|
||||
field: 'product'
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
align: 'left',
|
||||
label: 'Description',
|
||||
field: 'description'
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
align: 'left',
|
||||
label: 'Categories',
|
||||
field: 'categories'
|
||||
},
|
||||
{name: 'image', align: 'left', label: 'Image', field: 'image'},
|
||||
{name: 'price', align: 'left', label: 'Price', field: 'price'},
|
||||
{
|
||||
name: 'quantity',
|
||||
align: 'left',
|
||||
label: 'Quantity',
|
||||
field: 'quantity'
|
||||
},
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
productDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
orderDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
},
|
||||
indexerDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getIndexers: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/diagonalley/api/v1/diagonalley/indexers?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.indexers = response.data.map(function (obj) {
|
||||
return mapDiagonAlley(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
openIndexerUpdateDialog: function (linkId) {
|
||||
var link = _.findWhere(this.indexers, {id: linkId})
|
||||
|
||||
this.indexerDialog.data = _.clone(link._data)
|
||||
this.indexerDialog.show = true
|
||||
},
|
||||
sendIndexerFormData: function () {
|
||||
if (this.indexerDialog.data.id) {
|
||||
} else {
|
||||
var data = {
|
||||
shopname: this.indexerDialog.data.shopname,
|
||||
indexeraddress: this.indexerDialog.data.indexeraddress,
|
||||
shippingzone1: this.indexerDialog.data.shippingzone1.join(', '),
|
||||
zone1cost: this.indexerDialog.data.zone1cost,
|
||||
shippingzone2: this.indexerDialog.data.shippingzone2.join(', '),
|
||||
zone2cost: this.indexerDialog.data.zone2cost,
|
||||
email: this.indexerDialog.data.email
|
||||
}
|
||||
}
|
||||
|
||||
if (this.indexerDialog.data.id) {
|
||||
this.updateIndexer(this.indexerDialog.data)
|
||||
} else {
|
||||
this.createIndexer(data)
|
||||
}
|
||||
},
|
||||
updateIndexer: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/diagonalley/api/v1/diagonalley/indexers' + data.id,
|
||||
_.findWhere(this.g.user.wallets, {
|
||||
id: this.indexerDialog.data.wallet
|
||||
}).inkey,
|
||||
_.pick(
|
||||
data,
|
||||
'shopname',
|
||||
'indexeraddress',
|
||||
'shippingzone1',
|
||||
'zone1cost',
|
||||
'shippingzone2',
|
||||
'zone2cost',
|
||||
'email'
|
||||
)
|
||||
)
|
||||
.then(function (response) {
|
||||
self.indexers = _.reject(self.indexers, function (obj) {
|
||||
return obj.id == data.id
|
||||
})
|
||||
self.indexers.push(mapDiagonAlley(response.data))
|
||||
self.indexerDialog.show = false
|
||||
self.indexerDialog.data = {}
|
||||
data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createIndexer: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/diagonalley/api/v1/diagonalley/indexers',
|
||||
_.findWhere(this.g.user.wallets, {
|
||||
id: this.indexerDialog.data.wallet
|
||||
}).inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.indexers.push(mapDiagonAlley(response.data))
|
||||
self.indexerDialog.show = false
|
||||
self.indexerDialog.data = {}
|
||||
data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteIndexer: function (indexerId) {
|
||||
var self = this
|
||||
var indexer = _.findWhere(this.indexers, {id: indexerId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this Indexer link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/diagonalley/api/v1/diagonalley/indexers/' + indexerId,
|
||||
_.findWhere(self.g.user.wallets, {id: indexer.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.indexers = _.reject(self.indexers, function (obj) {
|
||||
return obj.id == indexerId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportIndexersCSV: function () {
|
||||
LNbits.utils.exportCSV(this.indexersTable.columns, this.indexers)
|
||||
},
|
||||
getOrders: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/diagonalley/api/v1/diagonalley/orders?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.orders = response.data.map(function (obj) {
|
||||
return mapDiagonAlley(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
createOrder: function () {
|
||||
var data = {
|
||||
address: this.orderDialog.data.address,
|
||||
email: this.orderDialog.data.email,
|
||||
quantity: this.orderDialog.data.quantity,
|
||||
shippingzone: this.orderDialog.data.shippingzone
|
||||
}
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/diagonalley/api/v1/diagonalley/orders',
|
||||
_.findWhere(this.g.user.wallets, {id: this.orderDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.orders.push(mapDiagonAlley(response.data))
|
||||
self.orderDialog.show = false
|
||||
self.orderDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteOrder: function (orderId) {
|
||||
var self = this
|
||||
var order = _.findWhere(this.orders, {id: orderId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this order link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/diagonalley/api/v1/diagonalley/orders/' + orderId,
|
||||
_.findWhere(self.g.user.wallets, {id: order.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.orders = _.reject(self.orders, function (obj) {
|
||||
return obj.id == orderId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
shipOrder: function (order_id) {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/diagonalley/api/v1/diagonalley/orders/shipped/' + order_id,
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.orders = response.data.map(function (obj) {
|
||||
return mapDiagonAlley(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportOrdersCSV: function () {
|
||||
LNbits.utils.exportCSV(this.ordersTable.columns, this.orders)
|
||||
},
|
||||
getProducts: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/diagonalley/api/v1/diagonalley/products?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.products = response.data.map(function (obj) {
|
||||
return mapDiagonAlley(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
openProductUpdateDialog: function (linkId) {
|
||||
var link = _.findWhere(this.products, {id: linkId})
|
||||
|
||||
this.productDialog.data = _.clone(link._data)
|
||||
this.productDialog.show = true
|
||||
},
|
||||
sendProductFormData: function () {
|
||||
if (this.productDialog.data.id) {
|
||||
} else {
|
||||
var data = {
|
||||
product: this.productDialog.data.product,
|
||||
categories: this.productDialog.data.categories,
|
||||
description: this.productDialog.data.description,
|
||||
image: this.productDialog.data.image,
|
||||
price: this.productDialog.data.price,
|
||||
quantity: this.productDialog.data.quantity
|
||||
}
|
||||
}
|
||||
if (this.productDialog.data.id) {
|
||||
this.updateProduct(this.productDialog.data)
|
||||
} else {
|
||||
this.createProduct(data)
|
||||
}
|
||||
},
|
||||
updateProduct: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/diagonalley/api/v1/diagonalley/products' + data.id,
|
||||
_.findWhere(this.g.user.wallets, {
|
||||
id: this.productDialog.data.wallet
|
||||
}).inkey,
|
||||
_.pick(
|
||||
data,
|
||||
'shopname',
|
||||
'indexeraddress',
|
||||
'shippingzone1',
|
||||
'zone1cost',
|
||||
'shippingzone2',
|
||||
'zone2cost',
|
||||
'email'
|
||||
)
|
||||
)
|
||||
.then(function (response) {
|
||||
self.products = _.reject(self.products, function (obj) {
|
||||
return obj.id == data.id
|
||||
})
|
||||
self.products.push(mapDiagonAlley(response.data))
|
||||
self.productDialog.show = false
|
||||
self.productDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
createProduct: function (data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/diagonalley/api/v1/diagonalley/products',
|
||||
_.findWhere(this.g.user.wallets, {
|
||||
id: this.productDialog.data.wallet
|
||||
}).inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.products.push(mapDiagonAlley(response.data))
|
||||
self.productDialog.show = false
|
||||
self.productDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteProduct: function (productId) {
|
||||
var self = this
|
||||
var product = _.findWhere(this.products, {id: productId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this products link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/diagonalley/api/v1/diagonalley/products/' + productId,
|
||||
_.findWhere(self.g.user.wallets, {id: product.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.products = _.reject(self.products, function (obj) {
|
||||
return obj.id == productId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportProductsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.productsTable.columns, this.products)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getProducts()
|
||||
this.getOrders()
|
||||
this.getIndexers()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
<script>
|
||||
console.log('{{ stall }}')
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
from quart import g, render_template
|
||||
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from lnbits.extensions.diagonalley import diagonalley_ext
|
||||
|
||||
|
||||
@diagonalley_ext.route("/")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("diagonalley/index.html", user=g.user)
|
||||
@@ -0,0 +1,302 @@
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.core.crud import get_user
|
||||
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
|
||||
|
||||
from lnbits.extensions.diagonalley import diagonalley_ext
|
||||
from .crud import (
|
||||
create_diagonalleys_product,
|
||||
get_diagonalleys_product,
|
||||
get_diagonalleys_products,
|
||||
delete_diagonalleys_product,
|
||||
create_diagonalleys_indexer,
|
||||
update_diagonalleys_indexer,
|
||||
get_diagonalleys_indexer,
|
||||
get_diagonalleys_indexers,
|
||||
delete_diagonalleys_indexer,
|
||||
create_diagonalleys_order,
|
||||
get_diagonalleys_order,
|
||||
get_diagonalleys_orders,
|
||||
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
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
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)]), 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},
|
||||
}
|
||||
)
|
||||
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."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if product.wallet != g.wallet.id:
|
||||
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()), 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")
|
||||
async def api_diagonalley_products_delete(product_id):
|
||||
product = get_diagonalleys_product(product_id)
|
||||
|
||||
if not product:
|
||||
return jsonify({"message": "Product does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if product.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your Diagon Alley."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_diagonalleys_product(product_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
###Indexers
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
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)]), 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},
|
||||
}
|
||||
)
|
||||
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."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if indexer.wallet != g.wallet.id:
|
||||
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()), 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")
|
||||
async def api_diagonalley_indexer_delete(indexer_id):
|
||||
indexer = get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
if not indexer:
|
||||
return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if indexer.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_diagonalleys_indexer(indexer_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
###Orders
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
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)]), 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},
|
||||
}
|
||||
)
|
||||
async def api_diagonalley_order_create():
|
||||
order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data)
|
||||
return jsonify(order._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders/<order_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
async def api_diagonalley_order_delete(order_id):
|
||||
order = get_diagonalleys_order(order_id)
|
||||
|
||||
if not order:
|
||||
return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if order.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_diagonalleys_indexer(order_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders/paid/<order_id>", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
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 "", HTTPStatus.OK
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/orders/shipped/<order_id>", methods=["GET"])
|
||||
@api_check_wallet_key(key_type="invoice")
|
||||
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,
|
||||
),
|
||||
)
|
||||
order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
|
||||
|
||||
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"])
|
||||
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."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
products = db.fetchone("SELECT * FROM products WHERE wallet = ?", (rows[1],))
|
||||
if not products:
|
||||
return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND
|
||||
|
||||
return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), HTTPStatus.OK
|
||||
|
||||
|
||||
###Check a product has been shipped
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"])
|
||||
async def api_diagonalleys_stall_checkshipped(checking_id):
|
||||
with open_ext_db("diagonalley") as db:
|
||||
rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,))
|
||||
|
||||
return jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK
|
||||
|
||||
|
||||
###Place order
|
||||
|
||||
|
||||
@diagonalley_ext.route("/api/v1/diagonalley/stall/order/<indexer_id>", methods=["POST"])
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"id": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
"address": {"type": "string", "empty": False, "required": True},
|
||||
"quantity": {"type": "integer", "empty": False, "required": True},
|
||||
"shippingzone": {"type": "integer", "empty": False, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_diagonalley_stall_order(indexer_id):
|
||||
product = get_diagonalleys_product(g.data["id"])
|
||||
shipping = get_diagonalleys_indexer(indexer_id)
|
||||
|
||||
if g.data["shippingzone"] == 1:
|
||||
shippingcost = shipping.zone1cost
|
||||
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")
|
||||
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,
|
||||
),
|
||||
)
|
||||
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,7 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
events_ext = 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "LNEVENTS",
|
||||
"short_description": "LN tickets for events",
|
||||
"ion_icon": "calendar"
|
||||
"name": "Events",
|
||||
"short_description": "Sell and register event tickets",
|
||||
"icon": "local_activity",
|
||||
"contributors": ["benarc"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.db import open_ext_db
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .models import Tickets, Events
|
||||
|
||||
|
||||
#######TICKETS########
|
||||
|
||||
|
||||
def create_ticket(payment_hash: str, wallet: str, event: str, name: str, email: str) -> Tickets:
|
||||
with open_ext_db("events") as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO ticket (id, wallet, event, name, email, registered, paid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(payment_hash, wallet, event, name, email, False, False),
|
||||
)
|
||||
|
||||
return get_ticket(payment_hash)
|
||||
|
||||
|
||||
def update_ticket(paid: bool, payment_hash: str) -> Tickets:
|
||||
with open_ext_db("events") as db:
|
||||
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
|
||||
if row[6] == True:
|
||||
return get_ticket(payment_hash)
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE ticket
|
||||
SET paid = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(paid, payment_hash),
|
||||
)
|
||||
|
||||
eventdata = get_event(row[2])
|
||||
sold = eventdata.sold + 1
|
||||
amount_tickets = eventdata.amount_tickets - 1
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE events
|
||||
SET sold = ?, amount_tickets = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(sold, amount_tickets, row[2]),
|
||||
)
|
||||
return get_ticket(payment_hash)
|
||||
|
||||
|
||||
def get_ticket(payment_hash: str) -> Optional[Tickets]:
|
||||
with open_ext_db("events") as db:
|
||||
row = db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
|
||||
|
||||
return Tickets(**row) if row else None
|
||||
|
||||
|
||||
def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
with open_ext_db("events") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
print("scrum")
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_ticket(payment_hash: str) -> None:
|
||||
with open_ext_db("events") as db:
|
||||
db.execute("DELETE FROM ticket WHERE id = ?", (payment_hash,))
|
||||
|
||||
|
||||
########EVENTS#########
|
||||
|
||||
|
||||
def create_event(
|
||||
*,
|
||||
wallet: str,
|
||||
name: str,
|
||||
info: str,
|
||||
closing_date: str,
|
||||
event_start_date: str,
|
||||
event_end_date: str,
|
||||
amount_tickets: int,
|
||||
price_per_ticket: int,
|
||||
) -> Events:
|
||||
with open_ext_db("events") as db:
|
||||
event_id = urlsafe_short_hash()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
event_id,
|
||||
wallet,
|
||||
name,
|
||||
info,
|
||||
closing_date,
|
||||
event_start_date,
|
||||
event_end_date,
|
||||
amount_tickets,
|
||||
price_per_ticket,
|
||||
0,
|
||||
),
|
||||
)
|
||||
print(event_id)
|
||||
|
||||
return get_event(event_id)
|
||||
|
||||
|
||||
def update_event(event_id: str, **kwargs) -> Events:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
with open_ext_db("events") as db:
|
||||
db.execute(f"UPDATE events SET {q} WHERE id = ?", (*kwargs.values(), event_id))
|
||||
|
||||
row = db.fetchone("SELECT * FROM events WHERE id = ?", (event_id,))
|
||||
|
||||
return Events(**row) if row else None
|
||||
|
||||
|
||||
def get_event(event_id: str) -> Optional[Events]:
|
||||
with open_ext_db("events") as db:
|
||||
row = db.fetchone("SELECT * FROM events WHERE id = ?", (event_id,))
|
||||
|
||||
return Events(**row) if row else None
|
||||
|
||||
|
||||
def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
with open_ext_db("events") as db:
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = db.fetchall(f"SELECT * FROM events WHERE wallet IN ({q})", (*wallet_ids,))
|
||||
|
||||
return [Events(**row) for row in rows]
|
||||
|
||||
|
||||
def delete_event(event_id: str) -> None:
|
||||
with open_ext_db("events") as db:
|
||||
db.execute("DELETE FROM events WHERE id = ?", (event_id,))
|
||||
|
||||
|
||||
########EVENTTICKETS#########
|
||||
|
||||
|
||||
def get_event_tickets(event_id: str, wallet_id: str) -> Tickets:
|
||||
|
||||
with open_ext_db("events") as db:
|
||||
rows = db.fetchall("SELECT * FROM ticket WHERE wallet = ? AND event = ?", (wallet_id, event_id))
|
||||
print(rows)
|
||||
|
||||
return [Tickets(**row) for row in rows]
|
||||
|
||||
|
||||
def reg_ticket(ticket_id: str) -> Tickets:
|
||||
with open_ext_db("events") as db:
|
||||
db.execute("UPDATE ticket SET registered = ? WHERE id = ?", (True, ticket_id))
|
||||
ticket = db.fetchone("SELECT * FROM ticket WHERE id = ?", (ticket_id,))
|
||||
print(ticket[1])
|
||||
rows = db.fetchall("SELECT * FROM ticket WHERE event = ?", (ticket[1],))
|
||||
|
||||
return [Tickets(**row) for row in rows]
|
||||
@@ -0,0 +1,85 @@
|
||||
def m001_initial(db):
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
info TEXT NOT NULL,
|
||||
closing_date TEXT NOT NULL,
|
||||
event_start_date TEXT NOT NULL,
|
||||
event_end_date TEXT NOT NULL,
|
||||
amount_tickets INTEGER NOT NULL,
|
||||
price_per_ticket INTEGER NOT NULL,
|
||||
sold INTEGER NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
registered BOOLEAN NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def m002_changed(db):
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ticket (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
registered BOOLEAN NOT NULL,
|
||||
paid BOOLEAN NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
for row in [list(row) for row in db.fetchall("SELECT * FROM tickets")]:
|
||||
usescsv = ""
|
||||
|
||||
for i in range(row[5]):
|
||||
if row[7]:
|
||||
usescsv += "," + str(i + 1)
|
||||
else:
|
||||
usescsv += "," + str(1)
|
||||
usescsv = usescsv[1:]
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO ticket (
|
||||
id,
|
||||
wallet,
|
||||
event,
|
||||
name,
|
||||
email,
|
||||
registered,
|
||||
paid
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row[0],
|
||||
row[1],
|
||||
row[2],
|
||||
row[3],
|
||||
row[4],
|
||||
row[5],
|
||||
True,
|
||||
),
|
||||
)
|
||||
db.execute("DROP TABLE tickets")
|
||||
@@ -0,0 +1,26 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class Events(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
name: str
|
||||
info: str
|
||||
closing_date: str
|
||||
event_start_date: str
|
||||
event_end_date: str
|
||||
amount_tickets: int
|
||||
price_per_ticket: int
|
||||
sold: int
|
||||
time: int
|
||||
|
||||
|
||||
class Tickets(NamedTuple):
|
||||
id: str
|
||||
wallet: str
|
||||
event: str
|
||||
name: str
|
||||
email: str
|
||||
registered: bool
|
||||
paid: bool
|
||||
time: int
|
||||
@@ -1,26 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
key INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
usr TEXT,
|
||||
wal TEXT,
|
||||
walnme TEXT,
|
||||
walinvkey INTEGER,
|
||||
uni TEXT,
|
||||
tit TEXT,
|
||||
cldate TEXT,
|
||||
notickets INTEGER,
|
||||
sold INTEGER DEFAULT 0,
|
||||
prtick INTEGER,
|
||||
descr TEXT,
|
||||
unireg TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS eventssold (
|
||||
key INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uni TEXT,
|
||||
email TEXT,
|
||||
name TEXT,
|
||||
hash TEXT,
|
||||
paid INTEGER DEFAULT 0,
|
||||
reg INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<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">
|
||||
Events: Sell and register ticket waves for an event
|
||||
</h5>
|
||||
<p>
|
||||
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.<br />
|
||||
<small>
|
||||
Created by, <a href="https://github.com/benarc">Ben Arc</a>
|
||||
</small>
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -1,570 +1,204 @@
|
||||
<!-- @format -->
|
||||
{% extends "public.html" %} {% 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 class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h3 class="q-my-none">{{ event_name }}</h3>
|
||||
<br />
|
||||
<h5 class="q-my-none">{{ event_info }}</h5>
|
||||
<br />
|
||||
<q-form @submit="Invoice()" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
type="name"
|
||||
label="Your name "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.email"
|
||||
type="email"
|
||||
label="Your email "
|
||||
></q-input>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>LNBits Wallet</title>
|
||||
<meta
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
|
||||
name="viewport"
|
||||
/>
|
||||
<!-- Bootstrap 3.3.2 -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}"
|
||||
/>
|
||||
<!-- FontAwesome 4.3.0 -->
|
||||
<link
|
||||
href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<!-- Ionicons 2.0.0 -->
|
||||
<link
|
||||
href="https://code.ionicframework.com/ionicons/2.0.0/css/ionicons.min.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<!-- Theme style -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='dist/css/AdminLTE.min.css') }}"
|
||||
/>
|
||||
<!-- AdminLTE Skins. Choose a skin from the css/skins
|
||||
folder instead of downloading all of them to reduce the load. -->
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='dist/css/skins/_all-skins.min.css') }}"
|
||||
/>
|
||||
|
||||
<!-- Morris chart -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='plugins/morris/morris.css') }}"
|
||||
/>
|
||||
|
||||
<!-- jvectormap -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='plugins/jvectormap/jquery-jvectormap-1.2.2.css') }}"
|
||||
/>
|
||||
|
||||
<!-- bootstrap wysihtml5 - text editor -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='plugins/bootstrap-wysihtml5/bootstrap3-wysihtml5.min.css') }}"
|
||||
/>
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
<style>
|
||||
.small-box > .small-box-footer {
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
#loadingMessage {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#output {
|
||||
margin-top: 20px;
|
||||
background: #eee;
|
||||
padding: 10px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#output div {
|
||||
padding-bottom: 10px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#noQRFound {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- jQuery 2.1.3 -->
|
||||
<script src="{{ url_for('static', filename='plugins/jQuery/jQuery-2.1.3.min.js') }}"></script>
|
||||
<!-- jQuery UI 1.11.2 -->
|
||||
<script
|
||||
src="https://code.jquery.com/ui/1.11.2/jquery-ui.min.js"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Resolve conflict in jQuery UI tooltip with Bootstrap tooltip -->
|
||||
<script>
|
||||
$.widget.bridge('uibutton', $.ui.button)
|
||||
</script>
|
||||
<!-- Bootstrap 3.3.2 JS -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Morris.js charts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/morris/morris.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Sparkline -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/sparkline/jquery.sparkline.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- jvectormap -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jvectormap/jquery-jvectormap-1.2.2.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jvectormap/jquery-jvectormap-world-mill-en.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- jQuery Knob Chart -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/knob/jquery.knob.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Bootstrap WYSIHTML5 -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/bootstrap-wysihtml5/bootstrap3-wysihtml5.all.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Slimscroll -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/slimScroll/jquery.slimscroll.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- FastClick -->
|
||||
<script src="{{ url_for('static', filename='plugins/fastclick/fastclick.min.js') }}"></script>
|
||||
<!-- AdminLTE App -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='dist/js/app.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<!-- AdminLTE dashboard demo (This is only for demo purposes) -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='dist/js/pages/dashboard.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<!-- AdminLTE for demo purposes -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='dist/js/demo.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/datatables/jquery.dataTables.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="//cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.css"
|
||||
/>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.min.js"></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jscam/JS.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jscam/qrcode.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/bolt11/decoder.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/bolt11/utils.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
|
||||
//GOOFY CSS HACK TO GO DARK
|
||||
|
||||
.skin-blue .wrapper {
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li.active > a {
|
||||
color: #fff;
|
||||
background:#1f2234;
|
||||
border-left-color:#8964a9;
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar {
|
||||
background-color:
|
||||
#2e507d;
|
||||
}
|
||||
|
||||
.content-wrapper, .right-side {
|
||||
background-color:
|
||||
#1f2234;
|
||||
}
|
||||
.skin-blue .main-header .logo {
|
||||
background-color:
|
||||
#1f2234;
|
||||
color:
|
||||
#fff;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li.header {
|
||||
color:
|
||||
#4b646f;
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
.skin-blue .wrapper, .skin-blue .main-sidebar, .skin-blue .left-side {
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li > .treeview-menu {
|
||||
margin: 0 1px;
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li > a {
|
||||
border-left: 3px solid
|
||||
transparent;
|
||||
margin-right: 1px;
|
||||
}
|
||||
.skin-blue .sidebar-menu > li > a:hover, .skin-blue .sidebar-menu > li.active > a {
|
||||
|
||||
color: #fff;
|
||||
background:#3e355a;
|
||||
border-left-color:#8964a9;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.skin-blue .main-header .logo:hover {
|
||||
background:
|
||||
#3e355a;
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar .sidebar-toggle:hover {
|
||||
background-color:
|
||||
#3e355a;
|
||||
}
|
||||
.main-footer {
|
||||
background-color: #1f2234;
|
||||
padding: 15px;
|
||||
color: #fff;
|
||||
border-top: 0px;
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar {
|
||||
background-color: #1f2234;
|
||||
}
|
||||
|
||||
.bg-red, .callout.callout-danger, .alert-danger, .alert-error, .label-danger, .modal-danger .modal-body {
|
||||
background-color:
|
||||
#1f2234 !important;
|
||||
}
|
||||
.alert-danger, .alert-error {
|
||||
|
||||
border-color: #fff;
|
||||
border: 1px solid
|
||||
|
||||
#fff;
|
||||
border-radius: 7px;
|
||||
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar .nav > li > a:hover, .skin-blue .main-header .navbar .nav > li > a:active, .skin-blue .main-header .navbar .nav > li > a:focus, .skin-blue .main-header .navbar .nav .open > a, .skin-blue .main-header .navbar .nav .open > a:hover, .skin-blue .main-header .navbar .nav .open > a:focus {
|
||||
color:
|
||||
#f6f6f6;
|
||||
background-color: #3e355a;
|
||||
}
|
||||
.bg-aqua, .callout.callout-info, .alert-info, .label-info, .modal-info .modal-body {
|
||||
background-color:
|
||||
#3e355a !important;
|
||||
}
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
background-color: #333646;
|
||||
border-top: 3px solid #8964a9;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
.table-striped > tbody > tr:nth-of-type(2n+1) {
|
||||
background-color:
|
||||
#333646;
|
||||
}
|
||||
|
||||
.box-header {
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.box.box-danger {
|
||||
border-top-color: #8964a9;
|
||||
}
|
||||
.box.box-primary {
|
||||
border-top-color: #8964a9;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #8964a9;
|
||||
}
|
||||
.box-header.with-border {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
a:hover, a:active, a:focus {
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
// .modal.in .modal-dialog{
|
||||
// color:#000;
|
||||
// }
|
||||
|
||||
.form-control {
|
||||
|
||||
background-color:#333646;
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
.box-footer {
|
||||
|
||||
border-top: none;
|
||||
|
||||
background-color:
|
||||
#333646;
|
||||
}
|
||||
.modal-footer {
|
||||
|
||||
border-top: none;
|
||||
|
||||
}
|
||||
.modal-content {
|
||||
|
||||
background-color:
|
||||
#333646;
|
||||
}
|
||||
.modal.in .modal-dialog {
|
||||
|
||||
background-color: #333646;
|
||||
|
||||
}
|
||||
|
||||
.layout-boxed {
|
||||
background: none;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
background-color:
|
||||
#3e355a;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li > a:hover, .skin-blue .sidebar-menu > li.active > a {
|
||||
|
||||
background: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body class="skin-blue layout-boxed sidebar-collapse sidebar-open">
|
||||
<div class="wrapper">
|
||||
<header class="main-header">
|
||||
<!-- Logo -->
|
||||
<a href="{{ url_for('core.home') }}" class="logo"><b>LN</b>bits</a>
|
||||
<!-- Header Navbar: style can be found in header.less -->
|
||||
<nav class="navbar navbar-static-top" role="navigation">
|
||||
<!-- Sidebar toggle button-->
|
||||
<a
|
||||
href="#"
|
||||
class="sidebar-toggle"
|
||||
data-toggle="offcanvas"
|
||||
role="button"
|
||||
>
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
</a>
|
||||
<div class="navbar-custom-menu">
|
||||
<ul class="nav navbar-nav">
|
||||
<!-- Messages: style can be found in dropdown.less-->
|
||||
<li class="dropdown messages-menu">
|
||||
|
||||
{% block messages %}{% endblock %}
|
||||
</li>
|
||||
</ul>
|
||||
<p>{% raw %}{{amountWords}}{% endraw %}</p>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="formDialog.data.name == '' || formDialog.data.email == '' || paymentReq"
|
||||
type="submit"
|
||||
>Submit</q-btn
|
||||
>
|
||||
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<aside class="main-sidebar">
|
||||
<!-- sidebar: style can be found in sidebar.less -->
|
||||
<section class="sidebar" style="height: auto;">
|
||||
<q-card v-show="ticketLink.show" class="q-pa-lg">
|
||||
<div class="text-center q-mb-lg">
|
||||
<a :href="ticketLink.data.link" target="_blank">
|
||||
<q-btn unelevated size="xl" color="deep-purple"
|
||||
>Link to your ticket!</q-btn
|
||||
></a
|
||||
>
|
||||
<br /><br />
|
||||
<p>You'll be redirected in a few moments...</p>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<!-- /.sidebar -->
|
||||
</aside>
|
||||
<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-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="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>
|
||||
</div>
|
||||
|
||||
<!-- Right side column. Contains the navbar and content of the page -->
|
||||
<div class="content-wrapper">
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
LNBits Events
|
||||
<small>Lightning powered tickets</small>
|
||||
</h1>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
console.log('{{ form_costpword }}')
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Main content -->
|
||||
<section class="content"><br/><br/>
|
||||
<center><h1 style="font-size:500%">{{ nme }}</h1></center>
|
||||
<center><h2 style="width:55%;word-wrap: break-word;" >{{ descr }}</h2></center>
|
||||
<div id="theform">
|
||||
<br/><br/><br/>
|
||||
<center>
|
||||
|
||||
<form role="form">
|
||||
|
||||
<div class="form-group" style="width:300px;">
|
||||
|
||||
<input id="Nam" type="text" class="form-control" placeholder="Name"></input>
|
||||
<input id="Ema" type="text" class="form-control" placeholder="Email"></input>
|
||||
</div>
|
||||
<button onclick="submitforticket()" type="button" class="btn btn-info">Go to payment</button><p style="color:red;" id="error"></p>
|
||||
</form>
|
||||
</div>
|
||||
</center>
|
||||
|
||||
<center><br/><br/> <div id="qrcode" style="width: 340px;"></div><br/><br/>
|
||||
<div style="width:55%;word-wrap: break-word;" id="qrcodetxt"></div> <br/></center>
|
||||
|
||||
</section><!-- /.content -->
|
||||
</div><!-- /.content-wrapper -->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
function postAjax(url, data, thekey, success) {
|
||||
var params =
|
||||
typeof data == 'string'
|
||||
? data
|
||||
: Object.keys(data)
|
||||
.map(function(k) {
|
||||
return encodeURIComponent(k) + '=' + encodeURIComponent(data[k])
|
||||
})
|
||||
.join('&')
|
||||
var xhr = window.XMLHttpRequest
|
||||
? new XMLHttpRequest()
|
||||
: new ActiveXObject('Microsoft.XMLHTTP')
|
||||
xhr.open('POST', url)
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState > 3 && xhr.status == 200) {
|
||||
success(xhr.responseText)
|
||||
}
|
||||
}
|
||||
xhr.setRequestHeader('Grpc-Metadata-macaroon', thekey)
|
||||
xhr.setRequestHeader('Content-Type', 'application/json')
|
||||
xhr.send(params)
|
||||
return xhr
|
||||
}
|
||||
|
||||
function getAjax(url, thekey, success) {
|
||||
var xhr = window.XMLHttpRequest
|
||||
? new XMLHttpRequest()
|
||||
: new ActiveXObject('Microsoft.XMLHTTP')
|
||||
xhr.open('GET', url, true)
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState > 3 && xhr.status == 200) {
|
||||
success(xhr.responseText)
|
||||
}
|
||||
}
|
||||
xhr.setRequestHeader('Grpc-Metadata-macaroon', thekey)
|
||||
xhr.setRequestHeader('Content-Type', 'application/json')
|
||||
|
||||
xhr.send()
|
||||
return xhr
|
||||
}
|
||||
|
||||
function submitforticket(){
|
||||
|
||||
nam = document.getElementById('Nam').value
|
||||
ema = document.getElementById('Ema').value
|
||||
|
||||
postAjax(
|
||||
"{{ url_for('events.api_getticket') }}?ema=" + ema,
|
||||
JSON.stringify({"unireg": "{{wave }}", "name": nam}),
|
||||
"filla",
|
||||
|
||||
function(data) {
|
||||
theinvoice = JSON.parse(data).pay_req
|
||||
thehash = JSON.parse(data).payment_hash
|
||||
|
||||
new QRCode(document.getElementById('qrcode'), {
|
||||
text: theinvoice,
|
||||
width: 300,
|
||||
height: 300,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
})
|
||||
document.getElementById('theform').innerHTML = ""
|
||||
|
||||
document.getElementById("qrcode").style.backgroundColor = "white";
|
||||
document.getElementById("qrcode").style.padding = "20px";
|
||||
|
||||
|
||||
document.getElementById('qrcodetxt').innerHTML = theinvoice + "<br/><br/>"
|
||||
|
||||
var refreshId = setInterval(function(){
|
||||
|
||||
getAjax('/api/v1/invoice/' + thehash, "{{wave}}", function(datab) {
|
||||
console.log(JSON.parse(datab).PAID)
|
||||
if (JSON.parse(datab).PAID == 'TRUE') {
|
||||
location.replace("{{ url_for('events.ticket') }}?hash="+thehash + "&unireg={{wave}}")
|
||||
clearInterval(refreshId)
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
paymentReq: null,
|
||||
redirectUrl: null,
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {
|
||||
name: '',
|
||||
email: ''
|
||||
}
|
||||
},
|
||||
ticketLink: {
|
||||
show: false,
|
||||
data: {
|
||||
link: ''
|
||||
}
|
||||
},
|
||||
receive: {
|
||||
show: false,
|
||||
status: 'pending',
|
||||
paymentReq: null
|
||||
}
|
||||
})}, 3000);
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
})
|
||||
}
|
||||
methods: {
|
||||
resetForm: function (e) {
|
||||
e.preventDefault()
|
||||
this.formDialog.data.name = ''
|
||||
this.formDialog.data.email = ''
|
||||
},
|
||||
|
||||
closeReceiveDialog: function () {
|
||||
var checker = this.receive.paymentChecker
|
||||
dismissMsg()
|
||||
|
||||
clearInterval(paymentChecker)
|
||||
setTimeout(function () {}, 10000)
|
||||
},
|
||||
Invoice: function () {
|
||||
var self = this
|
||||
axios
|
||||
|
||||
</script>
|
||||
</html>
|
||||
.post(
|
||||
'/events/api/v1/tickets/' + '{{ event_id }}/{{ event_price }}',
|
||||
{
|
||||
event: '{{ event_id }}',
|
||||
event_name: '{{ event_name }}',
|
||||
name: self.formDialog.data.name,
|
||||
email: self.formDialog.data.email
|
||||
}
|
||||
)
|
||||
.then(function (response) {
|
||||
self.paymentReq = response.data.payment_request
|
||||
self.paymentCheck = response.data.payment_hash
|
||||
|
||||
dismissMsg = self.$q.notify({
|
||||
timeout: 0,
|
||||
message: 'Waiting for payment...'
|
||||
})
|
||||
|
||||
self.receive = {
|
||||
show: true,
|
||||
status: 'pending',
|
||||
paymentReq: self.paymentReq
|
||||
}
|
||||
|
||||
paymentChecker = setInterval(function () {
|
||||
axios
|
||||
.get('/events/api/v1/tickets/' + self.paymentCheck)
|
||||
.then(function (res) {
|
||||
if (res.data.paid) {
|
||||
clearInterval(paymentChecker)
|
||||
dismissMsg()
|
||||
self.formDialog.data.name = ''
|
||||
self.formDialog.data.email = ''
|
||||
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Sent, thank you!',
|
||||
icon: null
|
||||
})
|
||||
self.receive = {
|
||||
show: false,
|
||||
status: 'complete',
|
||||
paymentReq: null
|
||||
}
|
||||
|
||||
self.ticketLink = {
|
||||
show: true,
|
||||
data: {
|
||||
link: '/events/ticket/' + res.data.ticket_id
|
||||
}
|
||||
}
|
||||
setTimeout(function () {
|
||||
window.location.href =
|
||||
'/events/ticket/' + res.data.ticket_id
|
||||
}, 5000)
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "public.html" %} {% 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 class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<center>
|
||||
<h3 class="q-my-none">{{ event_name }} error</h3>
|
||||
<br />
|
||||
<q-icon
|
||||
name="warning"
|
||||
class="text-grey"
|
||||
style="font-size: 20rem"
|
||||
></q-icon>
|
||||
|
||||
<h5 class="q-my-none">{{ event_error }}</h5>
|
||||
<br />
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
</div>
|
||||
@@ -1,480 +1,507 @@
|
||||
<!-- @format -->
|
||||
{% 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 Event</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
{% extends "base.html" %} {% block messages %}
|
||||
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||
<i class="fa fa-bell-o"></i>
|
||||
<span class="label label-danger">!</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="header"><b>Instant wallet, bookmark to save</b></li>
|
||||
<li></li>
|
||||
</ul>
|
||||
{% endblock %}
|
||||
|
||||
{% block menuitems %}
|
||||
<li class="treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-bitcoin"></i> <span>Wallets</span>
|
||||
<i class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
{% for w in user_wallets %}
|
||||
<li>
|
||||
<a href="{{ url_for('wallet') }}?wal={{ w.id }}&usr={{ w.user }}"><i class="fa fa-bolt"></i> {{ w.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li><a onclick="sidebarmake()">Add a wallet +</a></li>
|
||||
<div id="sidebarmake"></div>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="active treeview">
|
||||
<a href="#">
|
||||
<i class="fa fa-th"></i> <span>Extensions</span>
|
||||
<i class="fa fa-angle-left pull-right"></i>
|
||||
</a>
|
||||
<ul class="treeview-menu">
|
||||
{% for extension in EXTENSIONS %}
|
||||
{% if extension.code in user_ext %}
|
||||
<li>
|
||||
<a href="{{ url_for(extension.code + '.index') }}?usr={{ user }}"><i class="fa fa-plus"></i> {{ extension.name }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li>
|
||||
<a href="{{ url_for('extensions') }}?usr={{ user }}">Manager</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block body %}
|
||||
<!-- Right side column. Contains the navbar and content of the page -->
|
||||
<div class="content-wrapper">
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
Events
|
||||
<small>bitcoin tickets</small>
|
||||
|
||||
</h1>
|
||||
<ol class="breadcrumb">
|
||||
<li>
|
||||
<a href="{{ url_for('wallet') }}?usr={{ user }}"><i class="fa fa-dashboard"></i> Home</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('extensions') }}?usr={{ user }}"><li class="fa fa-dashboard">Extensions</li></a>
|
||||
</li>
|
||||
<li>
|
||||
<i class="active" class="fa fa-dashboard">Lightning tickets</i>
|
||||
</li>
|
||||
</ol>
|
||||
<br /><br />
|
||||
</section>
|
||||
<style>
|
||||
.datepicker-days{
|
||||
|
||||
background-color: #1f2234;
|
||||
}
|
||||
|
||||
</style>
|
||||
<!-- Main content -->
|
||||
<section class="content">
|
||||
<!-- Small boxes (Stat box) -->
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md-6">
|
||||
<!-- general form elements -->
|
||||
<div class="box box-primary">
|
||||
|
||||
|
||||
<div class="box-header">
|
||||
<h3 class="box-title"> Make a ticket wave</h3>
|
||||
</div><!-- /.box-header -->
|
||||
|
||||
|
||||
<!-- form start -->
|
||||
<form role="form">
|
||||
<div class="box-body">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exampleInputEmail1">Ticket title</label>
|
||||
<input id="tit" type="text" pattern="^[A-Za-z]+$" class="form-control" >
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Description of event</label>
|
||||
<textarea id="descr" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- select -->
|
||||
<div class="form-group">
|
||||
<label>Select a wallet</label>
|
||||
<select id="wal" class="form-control">
|
||||
<option></option>
|
||||
{% for w in user_wallets %}
|
||||
<option>{{w.name}}-{{w.id}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nooftickets">No. of tickets</label>
|
||||
<input id="notickets" type="number" class="form-control" placeholder="10" max="86400"></input>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Close date:</label>
|
||||
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" id="datepicker"></input>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label for="prpertick">Price per ticket</label>
|
||||
<input id="prtickets" type="number" class="form-control" placeholder="10"></input>
|
||||
</div>
|
||||
|
||||
</div><!-- /.box-body -->
|
||||
|
||||
<div class="box-footer">
|
||||
|
||||
<button onclick="postev()" type="button" class="btn btn-info">Create Wave</button><p style="color:red;" id="error"></p>
|
||||
</div>
|
||||
</form>
|
||||
</div></div></div>
|
||||
|
||||
|
||||
|
||||
<div class="col-md-6">
|
||||
<!-- general form elements -->
|
||||
<div class="box box-primary">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">Select a link</h3>
|
||||
</div><!-- /.box-header -->
|
||||
<form role="form">
|
||||
<div class="box-body">
|
||||
<div class="form-group">
|
||||
|
||||
<select class="form-control" id="waveselect" onchange="drawwithdraw()">
|
||||
<option value="none" selected>
|
||||
Select an Option
|
||||
</option>
|
||||
{% for w in user_ev %}
|
||||
<option id="{{w.uni}}" value="{{w.tit}}-{{w.unireg}}-{{w.uni}}">{{w.tit}}-{{w.unireg}}-{{w.uni}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<center> <br/><div id="qrcode" style="width:340px" ></div><br/><div style="width:75%;word-wrap: break-word;" id="qrcodetxt" ></div></center>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div><!-- /.box -->
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">Ticket Waves<b id="withdraws"></b></h3>
|
||||
<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">Events</h5>
|
||||
</div>
|
||||
<!-- /.box-header -->
|
||||
<div class="box-body no-padding">
|
||||
<table id="pagnation" class="table table-bswearing anchorordered table-striped">
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th style="width:15%">Amt</th>
|
||||
<th style="width:15%">Sold</th>
|
||||
<th style="width:15%">Closing</th>
|
||||
<th style="width:15%">Price</th>
|
||||
<th style="width:15%">Wallet</th>
|
||||
<th style="width:10%">Edit</th>
|
||||
<th style="width:10%">Del</th>
|
||||
</tr>
|
||||
<tbody id="ticketwaves"></tbody>
|
||||
</table>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exporteventsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
<!-- /.box-body -->
|
||||
</div>
|
||||
<!-- /.box -->
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="events"
|
||||
row-key="id"
|
||||
:columns="eventsTable.columns"
|
||||
:pagination.sync="eventsTable.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="link"
|
||||
: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="how_to_reg"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/events/register/' + props.row.id"
|
||||
target="_blank"
|
||||
></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="updateformDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteEvent(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<div id="editlink"></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">Tickets</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportticketsCSV"
|
||||
>Export to CSV</q-btn
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="tickets"
|
||||
row-key="id"
|
||||
:columns="ticketsTable.columns"
|
||||
:pagination.sync="ticketsTable.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>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="local_activity"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/events/ticket/' + props.row.id"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
|
||||
<!-- /.content -->
|
||||
</section>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
|
||||
<script>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteTicket(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 Events extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "events/_api_docs.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
//Date picker
|
||||
$('#datepicker').datepicker({
|
||||
autoclose: true
|
||||
})
|
||||
|
||||
|
||||
|
||||
window.user = {{ user | megajson | safe }}
|
||||
window.user_wallets = {{ user_wallets | megajson | safe }}
|
||||
window.user_ext = {{ user_ext | megajson | safe }}
|
||||
window.user_ev = {{ user_ev | megajson | safe }}
|
||||
|
||||
const user_ev = window.user_ev
|
||||
console.log(user_ev)
|
||||
|
||||
|
||||
function drawChart(user_ev) {
|
||||
var transactionsHTML = ''
|
||||
|
||||
for (var i = 0; i < user_ev.length; i++) {
|
||||
var ev = user_ev[i]
|
||||
|
||||
transactionsHTML =
|
||||
"<tr><td style='width: 50%'>" +
|
||||
ev.tit +
|
||||
'</td><td>' +
|
||||
ev.notickets +
|
||||
'</td><td>' +
|
||||
ev.sold +
|
||||
'</td><td>' +
|
||||
ev.cldate +
|
||||
'</td><td>' +
|
||||
ev.prtick +
|
||||
'</td><td>' +
|
||||
"<a href='{{ url_for('wallet') }}?usr="+ ev.usr +"'>" + ev.wal.substring(0, 4) + "...</a>" +
|
||||
'</td><td>' +
|
||||
"<i onclick='editlink("+ i +")'' class='fa fa-edit'></i>" +
|
||||
'</td><td>' +
|
||||
"<b><a style='color:red;' href='" + "{{ url_for('events.index') }}?del=" + ev.uni + "&usr=" + ev.usr +"'>" + "<i class='fa fa-trash'></i>" + "</a></b>" +
|
||||
'</td></tr>' +
|
||||
transactionsHTML
|
||||
document.getElementById('ticketwaves').innerHTML = transactionsHTML
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (user_ev.length) {
|
||||
drawChart(user_ev)
|
||||
}
|
||||
|
||||
function postev(){
|
||||
|
||||
wal = document.getElementById('wal').value
|
||||
tit = document.getElementById('tit').value
|
||||
cldate = document.getElementById('datepicker').value
|
||||
notickets = document.getElementById('notickets').value
|
||||
prtickets = document.getElementById('prtickets').value
|
||||
descr = document.getElementById('descr').value
|
||||
|
||||
if (tit == "") {
|
||||
document.getElementById("error").innerHTML = "Only use letters in title"
|
||||
return amt
|
||||
}
|
||||
if (wal == "") {
|
||||
document.getElementById("error").innerHTML = "No wallet selected"
|
||||
return amt
|
||||
}
|
||||
if (cldate == "") {
|
||||
document.getElementById("error").innerHTML = "No date selected"
|
||||
return amt
|
||||
}
|
||||
|
||||
if (isNaN(notickets) || notickets < 1) {
|
||||
document.getElementById("error").innerHTML = "Must be more than 1"
|
||||
return amt
|
||||
}
|
||||
if (isNaN(prtickets) || prtickets < 10) {
|
||||
document.getElementById("error").innerHTML = "Must be higher 10"
|
||||
return amt
|
||||
}
|
||||
|
||||
postAjax(
|
||||
"{{ url_for('events.create') }}",
|
||||
JSON.stringify({"tit": tit, "usr": user, "wal": wal, "notickets": notickets,"cldate": cldate, "prtickets": prtickets, "descr": descr}),
|
||||
"filla",
|
||||
|
||||
function(data) { location.replace("{{ url_for('events.index') }}?usr=" + user)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
function editlink(evnum){
|
||||
|
||||
evdetails = user_ev[evnum]
|
||||
|
||||
console.log(evdetails.descr)
|
||||
wallpick = ""
|
||||
|
||||
checkbox = ""
|
||||
if (evdetails.uniq == 1){
|
||||
checkbox = "checked"}
|
||||
|
||||
document.getElementById('editlink').innerHTML = "<div class='row'>"+
|
||||
"<div class='col-md-6'>"+
|
||||
" <!-- general form elements -->"+
|
||||
"<div class='box box-primary' style='min-height: 300px;'>"+
|
||||
"<div class='box-header'>"+
|
||||
"<h3 class='box-title'> Edit: <i id='unid'>" + evdetails.tit + "-" + evdetails.uni + "-" + evdetails.unireg + "</i> </h3>"+
|
||||
"<div class='box-tools pull-right'>" +
|
||||
"<button class='btn btn-box-tool' data-widget='remove'><i class='fa fa-times'></i></button>" +
|
||||
"</div>" +
|
||||
" </div><!-- /.box-header -->"+
|
||||
" <!-- form start -->"+
|
||||
"<form role='form'>"+
|
||||
"<div class='box-body'>"+
|
||||
|
||||
"<div class='col-sm-3 col-md-4'>"+
|
||||
"<div class='form-group'>"+
|
||||
"<label for='exampleInputEmail1'>Link title</label>"+
|
||||
"<input id='edittit' type='text' class='form-control' value='"+
|
||||
evdetails.tit +
|
||||
"'></input> </div>"+
|
||||
" </div>"+
|
||||
|
||||
"<div class='col-sm-1 col-md-8'>"+
|
||||
"<div class='form-group'>"+
|
||||
"<label for='exampleInputEmail1'>Description of event</label>"+
|
||||
"<textarea id='editdescr' type='textarea' rows='1' class='form-control'>"+evdetails.descr+"</textarea> </div>"+
|
||||
" </div>"+
|
||||
|
||||
|
||||
" <div class='col-sm-4 col-md-4'>"+
|
||||
" <!-- select -->"+
|
||||
" <div class='form-group'>"+
|
||||
" <label>Select a wallet</label>"+
|
||||
"<select id='editwal' class='form-control'>"+
|
||||
" <option>" + evdetails.walnme + "-" + evdetails.wal + "</option>"+
|
||||
" {% for w in user_wallets %}"+
|
||||
|
||||
" <option>{{w.name}}-{{w.id}}</option>"+
|
||||
|
||||
" {% endfor %}"+
|
||||
" </select>"+
|
||||
" </div>"+
|
||||
" </div>"+
|
||||
" <div class='col-sm-3 col-md-4'>"+
|
||||
"<div class='form-group'>"+
|
||||
" <label for='exampleInputPassword1'>No of tickets:</label>"+
|
||||
" <input id='editnooftickets' type='number' class='form-control' placeholder='0' max='86400' value='"+
|
||||
evdetails.notickets +
|
||||
"'></input>"+
|
||||
"</div> </div>"+
|
||||
|
||||
" <div class='col-sm-3 col-md-4'>"+
|
||||
"<div class='form-group'>"+
|
||||
"<label for='exampleInputEmail1'>Price per ticket:</label>"+
|
||||
" <input id='editprtick' type='number' class='form-control' placeholder='1' value='"+
|
||||
evdetails.prtick +
|
||||
"'></input>"+
|
||||
" </div></div>"+
|
||||
" <div class='col-sm-3 col-md-4'>"+
|
||||
" <div class='input-group date'>"+
|
||||
" <label for='exampleInputEmail1'>Close date:</label>"+
|
||||
" <input id='datepicker2' type='text' class='form-control' placeholder='1' value='"+
|
||||
evdetails.cldate +
|
||||
"'></input>"+
|
||||
" </div></div>"+
|
||||
|
||||
|
||||
|
||||
" <div class='col-sm-3 col-md-4'>"+
|
||||
"</div><!-- /.box-body -->"+
|
||||
" </div><br/>"+
|
||||
" <div class='box-footer'>"+
|
||||
" <div class='col-sm-3 col-md-4'>"+
|
||||
"<button onclick='editlinkcont()' type='button' style='margin: 24px;' class='btn btn-info'>Edit link(s)</button>"+
|
||||
"</div>"+
|
||||
"<p style='color:red;' id='error2'>.</p>"+
|
||||
" </div></form></div><!-- /.box --></div></div>"
|
||||
|
||||
|
||||
}
|
||||
|
||||
//Date picker
|
||||
$('#datepicker2').datepicker({
|
||||
autoclose: true
|
||||
})
|
||||
|
||||
function editlinkcont(){
|
||||
|
||||
unid = document.getElementById('unid').innerHTML
|
||||
wal = document.getElementById('editwal').value
|
||||
tit = document.getElementById('edittit').value
|
||||
nooftickets = document.getElementById('editnooftickets').value
|
||||
prtick = document.getElementById('editprtick').value
|
||||
cldate = document.getElementById('datepicker2').value
|
||||
descr = document.getElementById('editdescr').value
|
||||
uni = unid.split("-")[1]
|
||||
|
||||
if (tit == "") {
|
||||
document.getElementById("error2").innerHTML = "Only use letters in title"
|
||||
return amt
|
||||
}
|
||||
if (wal == "") {
|
||||
document.getElementById("error2").innerHTML = "No wallet selected"
|
||||
return amt
|
||||
}
|
||||
|
||||
if (isNaN(nooftickets) || nooftickets < 1 || nooftickets > 1000000) {
|
||||
document.getElementById("error2").innerHTML = "No. of tickets must be between 1 - 1000000"
|
||||
return amt
|
||||
}
|
||||
if (isNaN(prtick) || prtick < 10 ) {
|
||||
document.getElementById("error2").innerHTML = "Ticket pricket must be higher than 10"
|
||||
return amt
|
||||
}
|
||||
|
||||
|
||||
postAjax(
|
||||
"{{ url_for('events.create') }}",
|
||||
JSON.stringify({"tit": tit, "usr": user, "wal": wal, "notickets": nooftickets,"cldate": cldate, "prtickets": prtick, "id": unid, "descr": descr}),
|
||||
|
||||
"filla",
|
||||
|
||||
function(data) { location.replace("{{ url_for('events.index') }}?usr=" + user)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
//draws withdraw QR code
|
||||
function drawwithdraw() {
|
||||
|
||||
document.getElementById("qrcode").innerHTML = "";
|
||||
walname = document.getElementById("waveselect").value
|
||||
|
||||
thewave = walname.split("-");
|
||||
console.log(window.location.hostname + "-" + thewave[1])
|
||||
toencode = "/events/wave/" + thewave[1]
|
||||
toreg = "/events/registration/" + thewave[2]
|
||||
|
||||
new QRCode(document.getElementById('qrcode'), {
|
||||
text: toencode,
|
||||
width: 300,
|
||||
height: 300,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
})
|
||||
|
||||
|
||||
document.getElementById('qrcodetxt').innerHTML = "<a href='" + toencode + "'><h2>Payment link</a> , " +
|
||||
"<a href='" + toreg + "'>Registration</h2></a>"
|
||||
|
||||
document.getElementById("qrcode").style.backgroundColor = "white";
|
||||
document.getElementById("qrcode").style.padding = "20px";
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
<q-dialog v-model="formDialog.show" position="top">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendEventData" 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="name"
|
||||
label="Name of event "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.info"
|
||||
type="textarea"
|
||||
label="Info about the event "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.closing_date"
|
||||
type="date"
|
||||
label="Closing for tickets "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_start_date"
|
||||
type="date"
|
||||
label="Event begins "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.event_end_date"
|
||||
type="date"
|
||||
label="Event ends "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.amount_tickets"
|
||||
type="number"
|
||||
label="Amount of tickets "
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.number="formDialog.data.price_per_ticket"
|
||||
type="number"
|
||||
label="Price per ticket "
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
type="submit"
|
||||
>Update Event</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="deep-purple"
|
||||
:disable="formDialog.data.wallet == null || formDialog.data.name == null || formDialog.data.info == null || formDialog.data.closing_date == null || formDialog.data.event_start_date == null || formDialog.data.event_end_date == null || formDialog.data.amount_tickets == null || formDialog.data.price_per_ticket == null"
|
||||
type="submit"
|
||||
>Create Event</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 %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapEvents = 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 = ['/events/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
events: [],
|
||||
tickets: [],
|
||||
eventsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{name: 'info', align: 'left', label: 'Info', field: 'info'},
|
||||
{
|
||||
name: 'event_start_date',
|
||||
align: 'left',
|
||||
label: 'Start date',
|
||||
field: 'event_start_date'
|
||||
},
|
||||
{
|
||||
name: 'event_end_date',
|
||||
align: 'left',
|
||||
label: 'End date',
|
||||
field: 'event_end_date'
|
||||
},
|
||||
{
|
||||
name: 'closing_date',
|
||||
align: 'left',
|
||||
label: 'Ticket close',
|
||||
field: 'closing_date'
|
||||
},
|
||||
{
|
||||
name: 'price_per_ticket',
|
||||
align: 'left',
|
||||
label: 'Price',
|
||||
field: 'price_per_ticket'
|
||||
},
|
||||
{
|
||||
name: 'amount_tickets',
|
||||
align: 'left',
|
||||
label: 'No tickets',
|
||||
field: 'amount_tickets'
|
||||
},
|
||||
{
|
||||
name: 'sold',
|
||||
align: 'left',
|
||||
label: 'Sold',
|
||||
field: 'sold'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
ticketsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||
{
|
||||
name: 'registered',
|
||||
align: 'left',
|
||||
label: 'Registered',
|
||||
field: 'registered'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTickets: function () {
|
||||
var self = this
|
||||
console.log('obj')
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/events/api/v1/tickets?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tickets = response.data.map(function (obj) {
|
||||
console.log(obj)
|
||||
return mapEvents(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
deleteTicket: function (ticketId) {
|
||||
var self = this
|
||||
var tickets = _.findWhere(this.tickets, {id: ticketId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this ticket')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/events/api/v1/tickets/' + ticketId,
|
||||
_.findWhere(self.g.user.wallets, {id: tickets.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tickets = _.reject(self.tickets, function (obj) {
|
||||
return obj.id == ticketId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportticketsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.ticketsTable.columns, this.tickets)
|
||||
},
|
||||
|
||||
getEvents: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/events/api/v1/events?all_wallets',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.events = response.data.map(function (obj) {
|
||||
return mapEvents(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
sendEventData: function () {
|
||||
var wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = this.formDialog.data
|
||||
|
||||
if (data.id) {
|
||||
this.updateEvent(wallet, data)
|
||||
} else {
|
||||
this.createEvent(wallet, data)
|
||||
}
|
||||
},
|
||||
|
||||
createEvent: function (wallet, data) {
|
||||
var self = this
|
||||
LNbits.api
|
||||
.request('POST', '/events/api/v1/events', wallet.inkey, data)
|
||||
.then(function (response) {
|
||||
self.events.push(mapEvents(response.data))
|
||||
self.formDialog.show = false
|
||||
self.formDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
updateformDialog: function (formId) {
|
||||
var link = _.findWhere(this.events, {id: formId})
|
||||
console.log(link.id)
|
||||
this.formDialog.data.id = link.id
|
||||
this.formDialog.data.wallet = link.wallet
|
||||
this.formDialog.data.name = link.name
|
||||
this.formDialog.data.info = link.info
|
||||
this.formDialog.data.closing_date = link.closing_date
|
||||
this.formDialog.data.event_start_date = link.event_start_date
|
||||
this.formDialog.data.event_end_date = link.event_end_date
|
||||
this.formDialog.data.amount_tickets = link.amount_tickets
|
||||
this.formDialog.data.price_per_ticket = link.price_per_ticket
|
||||
this.formDialog.show = true
|
||||
},
|
||||
updateEvent: function (wallet, data) {
|
||||
var self = this
|
||||
console.log(data)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/events/api/v1/events/' + data.id,
|
||||
wallet.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.events = _.reject(self.events, function (obj) {
|
||||
return obj.id == data.id
|
||||
})
|
||||
self.events.push(mapEvents(response.data))
|
||||
self.formDialog.show = false
|
||||
self.formDialog.data = {}
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteEvent: function (eventsId) {
|
||||
var self = this
|
||||
var events = _.findWhere(this.events, {id: eventsId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this form link?')
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/events/api/v1/events/' + eventsId,
|
||||
_.findWhere(self.g.user.wallets, {id: events.wallet}).inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.events = _.reject(self.events, function (obj) {
|
||||
return obj.id == eventsId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exporteventsCSV: function () {
|
||||
LNbits.utils.exportCSV(this.eventsTable.columns, this.events)
|
||||
}
|
||||
},
|
||||
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getTickets()
|
||||
this.getEvents()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
{% extends "public.html" %} {% 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 class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<center>
|
||||
<h3 class="q-my-none">{{ event_name }} Registration</h3>
|
||||
<br />
|
||||
|
||||
<br />
|
||||
|
||||
<q-btn unelevated color="deep-purple" @click="showCamera" size="xl"
|
||||
>Scan ticket</q-btn
|
||||
>
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="tickets"
|
||||
row-key="id"
|
||||
:columns="ticketsTable.columns"
|
||||
:pagination.sync="ticketsTable.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>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="local_activity"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'/events/ticket/' + props.row.id"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.value }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="sendCamera.show" position="top">
|
||||
<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>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
Vue.use(VueQrcodeReader)
|
||||
var mapEvents = 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 = ['/events/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
tickets: [],
|
||||
ticketsTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{
|
||||
name: 'registered',
|
||||
align: 'left',
|
||||
label: 'Registered',
|
||||
field: 'registered'
|
||||
}
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
sendCamera: {
|
||||
show: false,
|
||||
camera: 'auto'
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hoverEmail: function (tmp) {
|
||||
this.tickets.data.emailtemp = tmp
|
||||
},
|
||||
closeCamera: function () {
|
||||
this.sendCamera.show = false
|
||||
},
|
||||
showCamera: function () {
|
||||
this.sendCamera.show = true
|
||||
},
|
||||
decodeQR: function (res) {
|
||||
this.sendCamera.show = false
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request('GET', '/events/api/v1/register/ticket/' + res)
|
||||
.then(function (response) {
|
||||
self.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Registered!'
|
||||
})
|
||||
setTimeout(function () {
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
getEventTickets: function () {
|
||||
var self = this
|
||||
console.log('obj')
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/events/api/v1/eventtickets/{{ wallet_id }}/{{ event_id }}'
|
||||
)
|
||||
.then(function (response) {
|
||||
self.tickets = response.data.map(function (obj) {
|
||||
return mapEvents(obj)
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.getEventTickets()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,690 +0,0 @@
|
||||
<!-- @format -->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>LNBits Wallet</title>
|
||||
<meta
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
|
||||
name="viewport"
|
||||
/>
|
||||
<!-- Bootstrap 3.3.2 -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}"
|
||||
/>
|
||||
<!-- FontAwesome 4.3.0 -->
|
||||
<link
|
||||
href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<!-- Ionicons 2.0.0 -->
|
||||
<link
|
||||
href="https://code.ionicframework.com/ionicons/2.0.0/css/ionicons.min.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<!-- Theme style -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='dist/css/AdminLTE.min.css') }}"
|
||||
/>
|
||||
<!-- AdminLTE Skins. Choose a skin from the css/skins
|
||||
folder instead of downloading all of them to reduce the load. -->
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='dist/css/skins/_all-skins.min.css') }}"
|
||||
/>
|
||||
|
||||
<!-- Morris chart -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='plugins/morris/morris.css') }}"
|
||||
/>
|
||||
|
||||
<!-- jvectormap -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='plugins/jvectormap/jquery-jvectormap-1.2.2.css') }}"
|
||||
/>
|
||||
|
||||
<!-- bootstrap wysihtml5 - text editor -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='plugins/bootstrap-wysihtml5/bootstrap3-wysihtml5.min.css') }}"
|
||||
/>
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
<style>
|
||||
.small-box > .small-box-footer {
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
#loadingMessage {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#output {
|
||||
margin-top: 20px;
|
||||
background: #eee;
|
||||
padding: 10px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#output div {
|
||||
padding-bottom: 10px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#noQRFound {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- jQuery 2.1.3 -->
|
||||
<script src="{{ url_for('static', filename='plugins/jQuery/jQuery-2.1.3.min.js') }}"></script>
|
||||
<!-- jQuery UI 1.11.2 -->
|
||||
<script
|
||||
src="https://code.jquery.com/ui/1.11.2/jquery-ui.min.js"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Resolve conflict in jQuery UI tooltip with Bootstrap tooltip -->
|
||||
<script>
|
||||
$.widget.bridge('uibutton', $.ui.button)
|
||||
</script>
|
||||
<!-- Bootstrap 3.3.2 JS -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Morris.js charts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/morris/morris.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Sparkline -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/sparkline/jquery.sparkline.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- jvectormap -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jvectormap/jquery-jvectormap-1.2.2.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jvectormap/jquery-jvectormap-world-mill-en.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- jQuery Knob Chart -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/knob/jquery.knob.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Bootstrap WYSIHTML5 -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/bootstrap-wysihtml5/bootstrap3-wysihtml5.all.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Slimscroll -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/slimScroll/jquery.slimscroll.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- FastClick -->
|
||||
<script src="{{ url_for('static', filename='plugins/fastclick/fastclick.min.js') }}"></script>
|
||||
<!-- AdminLTE App -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='dist/js/app.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<!-- AdminLTE dashboard demo (This is only for demo purposes) -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='dist/js/pages/dashboard.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<!-- AdminLTE for demo purposes -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='dist/js/demo.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/datatables/jquery.dataTables.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="//cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.css"
|
||||
/>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.min.js"></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jscam/JS.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jscam/qrcode.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/bolt11/decoder.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/bolt11/utils.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
|
||||
//GOOFY CSS HACK TO GO DARK
|
||||
|
||||
.skin-blue .wrapper {
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li.active > a {
|
||||
color: #fff;
|
||||
background:#1f2234;
|
||||
border-left-color:#8964a9;
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar {
|
||||
background-color:
|
||||
#2e507d;
|
||||
}
|
||||
|
||||
.content-wrapper, .right-side {
|
||||
background-color:
|
||||
#1f2234;
|
||||
}
|
||||
.skin-blue .main-header .logo {
|
||||
background-color:
|
||||
#1f2234;
|
||||
color:
|
||||
#fff;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li.header {
|
||||
color:
|
||||
#4b646f;
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
.skin-blue .wrapper, .skin-blue .main-sidebar, .skin-blue .left-side {
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li > .treeview-menu {
|
||||
margin: 0 1px;
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li > a {
|
||||
border-left: 3px solid
|
||||
transparent;
|
||||
margin-right: 1px;
|
||||
}
|
||||
.skin-blue .sidebar-menu > li > a:hover, .skin-blue .sidebar-menu > li.active > a {
|
||||
|
||||
color: #fff;
|
||||
background:#3e355a;
|
||||
border-left-color:#8964a9;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.skin-blue .main-header .logo:hover {
|
||||
background:
|
||||
#3e355a;
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar .sidebar-toggle:hover {
|
||||
background-color:
|
||||
#3e355a;
|
||||
}
|
||||
.main-footer {
|
||||
background-color: #1f2234;
|
||||
padding: 15px;
|
||||
color: #fff;
|
||||
border-top: 0px;
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar {
|
||||
background-color: #1f2234;
|
||||
}
|
||||
|
||||
.bg-red, .callout.callout-danger, .alert-danger, .alert-error, .label-danger, .modal-danger .modal-body {
|
||||
background-color:
|
||||
#1f2234 !important;
|
||||
}
|
||||
.alert-danger, .alert-error {
|
||||
|
||||
border-color: #fff;
|
||||
border: 1px solid
|
||||
|
||||
#fff;
|
||||
border-radius: 7px;
|
||||
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar .nav > li > a:hover, .skin-blue .main-header .navbar .nav > li > a:active, .skin-blue .main-header .navbar .nav > li > a:focus, .skin-blue .main-header .navbar .nav .open > a, .skin-blue .main-header .navbar .nav .open > a:hover, .skin-blue .main-header .navbar .nav .open > a:focus {
|
||||
color:
|
||||
#f6f6f6;
|
||||
background-color: #3e355a;
|
||||
}
|
||||
.bg-aqua, .callout.callout-info, .alert-info, .label-info, .modal-info .modal-body {
|
||||
background-color:
|
||||
#3e355a !important;
|
||||
}
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
background-color: #333646;
|
||||
border-top: 3px solid #8964a9;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
.table-striped > tbody > tr:nth-of-type(2n+1) {
|
||||
background-color:
|
||||
#333646;
|
||||
}
|
||||
|
||||
.box-header {
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.box.box-danger {
|
||||
border-top-color: #8964a9;
|
||||
}
|
||||
.box.box-primary {
|
||||
border-top-color: #8964a9;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #8964a9;
|
||||
}
|
||||
.box-header.with-border {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
a:hover, a:active, a:focus {
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
// .modal.in .modal-dialog{
|
||||
// color:#000;
|
||||
// }
|
||||
|
||||
.form-control {
|
||||
|
||||
background-color:#333646;
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
.box-footer {
|
||||
|
||||
border-top: none;
|
||||
|
||||
background-color:
|
||||
#333646;
|
||||
}
|
||||
.modal-footer {
|
||||
|
||||
border-top: none;
|
||||
|
||||
}
|
||||
.modal-content {
|
||||
|
||||
background-color:
|
||||
#333646;
|
||||
}
|
||||
.modal.in .modal-dialog {
|
||||
|
||||
background-color: #333646;
|
||||
|
||||
}
|
||||
|
||||
.layout-boxed {
|
||||
background: none;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
background-color:
|
||||
#3e355a;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li > a:hover, .skin-blue .sidebar-menu > li.active > a {
|
||||
|
||||
background: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body class="skin-blue layout-boxed sidebar-collapse sidebar-open">
|
||||
<div class="wrapper">
|
||||
<header class="main-header">
|
||||
<!-- Logo -->
|
||||
<a href="{{ url_for('core.home') }}" class="logo"><b>LN</b>bits</a>
|
||||
<!-- Header Navbar: style can be found in header.less -->
|
||||
<nav class="navbar navbar-static-top" role="navigation">
|
||||
<!-- Sidebar toggle button-->
|
||||
<a
|
||||
href="#"
|
||||
class="sidebar-toggle"
|
||||
data-toggle="offcanvas"
|
||||
role="button"
|
||||
>
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
</a>
|
||||
<div class="navbar-custom-menu">
|
||||
<ul class="nav navbar-nav">
|
||||
<!-- Messages: style can be found in dropdown.less-->
|
||||
<li class="dropdown messages-menu">
|
||||
|
||||
{% block messages %}{% endblock %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<aside class="main-sidebar">
|
||||
<!-- sidebar: style can be found in sidebar.less -->
|
||||
<section class="sidebar" style="height: auto;">
|
||||
<!-- Sidebar user panel -->
|
||||
|
||||
|
||||
</section>
|
||||
<!-- /.sidebar -->
|
||||
</aside>
|
||||
|
||||
<!-- Right side column. Contains the navbar and content of the page -->
|
||||
<div class="content-wrapper">
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
LNBits Events
|
||||
<small>Lightning powered tickets</small>
|
||||
</h1>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Main content -->
|
||||
<section class="content"><br/><br/>
|
||||
<center><h1 style="font-size:500%">{{ user_ev[0][6] }}</h1></center>
|
||||
|
||||
|
||||
<br/><br/><br/>
|
||||
|
||||
<div class='modal fade sends' tabindex='-1' role='dialog' aria-labelledby='myLargeModalLabel' aria-hidden='true'>
|
||||
<div class='modal-dialog' >
|
||||
<div id='scantickets' style='padding: 0 10px 0 10px;'>
|
||||
</div></div></div>
|
||||
|
||||
|
||||
<center>
|
||||
<button
|
||||
onclick="scanQRsend()"
|
||||
class="btn btn-block btn-primary btn-lg"
|
||||
data-toggle="modal"
|
||||
data-target=".sends"
|
||||
style="width:300px;"
|
||||
>
|
||||
Scan ticket
|
||||
</button>
|
||||
</center>
|
||||
|
||||
|
||||
<div id="scantickets"></div>
|
||||
<br/><br/><br/>
|
||||
<div id="qrcodetxt"></div> <br/></center>
|
||||
<br/><br/><br/>
|
||||
|
||||
<center>
|
||||
<div class="row" style="width:80%;margin-top:80px">
|
||||
|
||||
|
||||
<style>
|
||||
.ema, button:focus .txt {
|
||||
display: none;
|
||||
}
|
||||
button:focus .ema {
|
||||
display: block;
|
||||
}
|
||||
|
||||
</style>
|
||||
<div class="box">
|
||||
<div class="box-header">
|
||||
<h3 class="box-title">Attendees<b id="withdraws"></b></h3>
|
||||
</div>
|
||||
<!-- /.box-header -->
|
||||
<div class="box-body no-padding">
|
||||
<table id="pagnation" class="table table-bswearing anchorordered table-striped">
|
||||
<tr>
|
||||
<th style="width:20%">Name</th>
|
||||
<th style="width:20%">Email</th>
|
||||
<th style="width:50%">Ticket</th>
|
||||
<th style="width:10%">Registered</th>
|
||||
</tr>
|
||||
<tbody id="ticketwaves"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- /.box-body -->
|
||||
</div>
|
||||
<!-- /.box -->
|
||||
|
||||
</div>
|
||||
</center>
|
||||
|
||||
</section><!-- /.content -->
|
||||
</div><!-- /.content-wrapper -->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
window.user_ev = {{ user_ev | megajson | safe }}
|
||||
window.user_ev_sold = {{ user_ev_sold | megajson | safe }}
|
||||
console.log(user_ev)
|
||||
console.log(user_ev_sold)
|
||||
|
||||
|
||||
function drawChart(user_ev_sold) {
|
||||
var transactionsHTML = ''
|
||||
|
||||
for (var i = 0; i < user_ev_sold.length; i++) {
|
||||
var ev = user_ev_sold[i]
|
||||
|
||||
transactionsHTML =
|
||||
"<tr><td>" +
|
||||
ev.name +
|
||||
'</td><td>' +
|
||||
'<button style="background-color: #333646;padding: 0;border: none;background: none;" class="lost"><span class="txt">xxxxxx</span>' +
|
||||
'<span class="ema">' + ev.email + '</span></button>'+
|
||||
'</td><td>' +
|
||||
ev.hash +
|
||||
'</td><td>' +
|
||||
ev.reg +
|
||||
'</td></tr>' +
|
||||
transactionsHTML
|
||||
document.getElementById('ticketwaves').innerHTML = transactionsHTML
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (user_ev_sold.length) {
|
||||
drawChart(user_ev_sold)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function postAjax(url, data, thekey, success) {
|
||||
var params =
|
||||
typeof data == 'string'
|
||||
? data
|
||||
: Object.keys(data)
|
||||
.map(function(k) {
|
||||
return encodeURIComponent(k) + '=' + encodeURIComponent(data[k])
|
||||
})
|
||||
.join('&')
|
||||
var xhr = window.XMLHttpRequest
|
||||
? new XMLHttpRequest()
|
||||
: new ActiveXObject('Microsoft.XMLHTTP')
|
||||
xhr.open('POST', url)
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState > 3 && xhr.status == 200) {
|
||||
success(xhr.responseText)
|
||||
}
|
||||
}
|
||||
xhr.setRequestHeader('Grpc-Metadata-macaroon', thekey)
|
||||
xhr.setRequestHeader('Content-Type', 'application/json')
|
||||
xhr.send(params)
|
||||
return xhr
|
||||
}
|
||||
|
||||
function getAjax(url, thekey, success) {
|
||||
var xhr = window.XMLHttpRequest
|
||||
? new XMLHttpRequest()
|
||||
: new ActiveXObject('Microsoft.XMLHTTP')
|
||||
xhr.open('GET', url, true)
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState > 3 && xhr.status == 200) {
|
||||
success(xhr.responseText)
|
||||
}
|
||||
}
|
||||
xhr.setRequestHeader('Grpc-Metadata-macaroon', thekey)
|
||||
xhr.setRequestHeader('Content-Type', 'application/json')
|
||||
|
||||
xhr.send()
|
||||
return xhr
|
||||
}
|
||||
|
||||
function scanQRsend() {
|
||||
document.getElementById('scantickets').innerHTML =
|
||||
"<div class='modal-content'>"+
|
||||
"<br/><div id='registered'><div id='loadingMessage'>🎥 Unable to access video stream (please make sure you have a webcam enabled)</div>" +
|
||||
"<canvas id='canvas' hidden></canvas><div id='output' hidden><div id='outputMessage'></div>" +
|
||||
"<br/><span id='outputData'></span></div></div><div class='modal-footer'>"+
|
||||
"<button type='submit' class='btn btn-primary' onclick='cancelsend()'>Cancel</button><br/><br/></div>"
|
||||
var video = document.createElement('video')
|
||||
var canvasElement = document.getElementById('canvas')
|
||||
var canvas = canvasElement.getContext('2d')
|
||||
var loadingMessage = document.getElementById('loadingMessage')
|
||||
var outputContainer = document.getElementById('output')
|
||||
var outputMessage = document.getElementById('outputMessage')
|
||||
var outputData = document.getElementById('outputData')
|
||||
|
||||
// Use facingMode: environment to attemt to get the front camera on phones
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({video: {facingMode: 'environment'}})
|
||||
.then(function(stream) {
|
||||
video.srcObject = stream
|
||||
video.setAttribute('playsinline', true) // required to tell iOS safari we don't want fullscreen
|
||||
video.play()
|
||||
requestAnimationFrame(tick)
|
||||
})
|
||||
|
||||
function tick() {
|
||||
loadingMessage.innerText = '⌛ Loading video...'
|
||||
if (video.readyState === video.HAVE_ENOUGH_DATA) {
|
||||
loadingMessage.hidden = true
|
||||
canvasElement.hidden = false
|
||||
outputContainer.hidden = false
|
||||
canvasElement.height = video.videoHeight
|
||||
canvasElement.width = video.videoWidth
|
||||
canvas.drawImage(video, 0, 0, canvasElement.width, canvasElement.height)
|
||||
var imageData = canvas.getImageData(
|
||||
0,
|
||||
0,
|
||||
canvasElement.width,
|
||||
canvasElement.height
|
||||
)
|
||||
var code = jsQR(imageData.data, imageData.width, imageData.height, {
|
||||
inversionAttempts: 'dontInvert'
|
||||
})
|
||||
if (code) {
|
||||
thehash = code.data
|
||||
document.getElementById('registered').innerHTML = "<h1 style='color:green;font-size:300%;'>Registered!</h1>"
|
||||
outputMessage.hidden = true
|
||||
outputData.parentElement.hidden = false
|
||||
outputData.innerText = JSON.stringify(code.data)
|
||||
getAjax("{{ url_for('events.api_checkticket') }}?thehash=" + thehash, "filla", function(datab) {
|
||||
if (JSON.parse(datab).status == 'TRUE') {
|
||||
location.reload()
|
||||
}
|
||||
})
|
||||
|
||||
} else {
|
||||
outputMessage.hidden = false
|
||||
outputData.parentElement.hidden = true
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelsend() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
|
||||
function getUrlVars() {
|
||||
var vars = {};
|
||||
var parts = window.location.href.replace(/[?&]+([^=&]+)=([^&]*)/gi, function(m,key,value) {
|
||||
vars[key] = value;
|
||||
});
|
||||
return vars;
|
||||
}
|
||||
var name = getUrlVars()["name"];
|
||||
var thehash = getUrlVars()["thehash"];
|
||||
console.log(thehash)
|
||||
if(thehash != null){
|
||||
document.getElementById('qrcodetxt').innerHTML = "<center><h1>" + name + " is registered!</h1></center>"
|
||||
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
</html>
|
||||
@@ -1,474 +1,45 @@
|
||||
<!-- @format -->
|
||||
{% extends "public.html" %} {% 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 class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<center>
|
||||
<h3 class="q-my-none">{{ ticket_name }} Ticket</h3>
|
||||
<br />
|
||||
<h5 class="q-my-none">
|
||||
Bookmark, print or screenshot this page,<br />
|
||||
and present it for registration!
|
||||
</h5>
|
||||
<br />
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>LNBits Wallet</title>
|
||||
<meta
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
|
||||
name="viewport"
|
||||
/>
|
||||
<!-- Bootstrap 3.3.2 -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='bootstrap/css/bootstrap.min.css') }}"
|
||||
/>
|
||||
<!-- FontAwesome 4.3.0 -->
|
||||
<link
|
||||
href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
<!-- Ionicons 2.0.0 -->
|
||||
<link
|
||||
href="https://code.ionicframework.com/ionicons/2.0.0/css/ionicons.min.css"
|
||||
rel="stylesheet"
|
||||
type="text/css"
|
||||
/>
|
||||
|
||||
<!-- Theme style -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='dist/css/AdminLTE.min.css') }}"
|
||||
/>
|
||||
<!-- AdminLTE Skins. Choose a skin from the css/skins
|
||||
folder instead of downloading all of them to reduce the load. -->
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='dist/css/skins/_all-skins.min.css') }}"
|
||||
/>
|
||||
|
||||
<!-- Morris chart -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='plugins/morris/morris.css') }}"
|
||||
/>
|
||||
|
||||
<!-- jvectormap -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='plugins/jvectormap/jquery-jvectormap-1.2.2.css') }}"
|
||||
/>
|
||||
|
||||
<!-- bootstrap wysihtml5 - text editor -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
media="screen"
|
||||
href="{{ url_for('static', filename='plugins/bootstrap-wysihtml5/bootstrap3-wysihtml5.min.css') }}"
|
||||
/>
|
||||
|
||||
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
|
||||
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
|
||||
<!--[if lt IE 9]>
|
||||
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
|
||||
<script src="https://oss.maxcdn.com/libs/respond.js/1.3.0/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
<style>
|
||||
.small-box > .small-box-footer {
|
||||
text-align: left;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
#loadingMessage {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#output {
|
||||
margin-top: 20px;
|
||||
background: #eee;
|
||||
padding: 10px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#output div {
|
||||
padding-bottom: 10px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#noQRFound {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- jQuery 2.1.3 -->
|
||||
<script src="{{ url_for('static', filename='plugins/jQuery/jQuery-2.1.3.min.js') }}"></script>
|
||||
<!-- jQuery UI 1.11.2 -->
|
||||
<script
|
||||
src="https://code.jquery.com/ui/1.11.2/jquery-ui.min.js"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Resolve conflict in jQuery UI tooltip with Bootstrap tooltip -->
|
||||
<script>
|
||||
$.widget.bridge('uibutton', $.ui.button)
|
||||
</script>
|
||||
<!-- Bootstrap 3.3.2 JS -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='bootstrap/js/bootstrap.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Morris.js charts -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/morris/morris.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Sparkline -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/sparkline/jquery.sparkline.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- jvectormap -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jvectormap/jquery-jvectormap-1.2.2.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jvectormap/jquery-jvectormap-world-mill-en.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- jQuery Knob Chart -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/knob/jquery.knob.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Bootstrap WYSIHTML5 -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/bootstrap-wysihtml5/bootstrap3-wysihtml5.all.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- Slimscroll -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/slimScroll/jquery.slimscroll.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<!-- FastClick -->
|
||||
<script src="{{ url_for('static', filename='plugins/fastclick/fastclick.min.js') }}"></script>
|
||||
<!-- AdminLTE App -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='dist/js/app.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<!-- AdminLTE dashboard demo (This is only for demo purposes) -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='dist/js/pages/dashboard.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<!-- AdminLTE for demo purposes -->
|
||||
<script
|
||||
src="{{ url_for('static', filename='dist/js/demo.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/datatables/jquery.dataTables.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="//cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.css"
|
||||
/>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/morris.js/0.5.1/morris.min.js"></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jscam/JS.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/jscam/qrcode.min.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/bolt11/decoder.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
<script
|
||||
src="{{ url_for('static', filename='plugins/bolt11/utils.js') }}"
|
||||
type="text/javascript"
|
||||
></script>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
|
||||
//GOOFY CSS HACK TO GO DARK
|
||||
|
||||
.skin-blue .wrapper {
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li.active > a {
|
||||
color: #fff;
|
||||
background:#1f2234;
|
||||
border-left-color:#8964a9;
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar {
|
||||
background-color:
|
||||
#2e507d;
|
||||
}
|
||||
|
||||
.content-wrapper, .right-side {
|
||||
background-color:
|
||||
#1f2234;
|
||||
}
|
||||
.skin-blue .main-header .logo {
|
||||
background-color:
|
||||
#1f2234;
|
||||
color:
|
||||
#fff;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li.header {
|
||||
color:
|
||||
#4b646f;
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
.skin-blue .wrapper, .skin-blue .main-sidebar, .skin-blue .left-side {
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li > .treeview-menu {
|
||||
margin: 0 1px;
|
||||
background:
|
||||
#1f2234;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li > a {
|
||||
border-left: 3px solid
|
||||
transparent;
|
||||
margin-right: 1px;
|
||||
}
|
||||
.skin-blue .sidebar-menu > li > a:hover, .skin-blue .sidebar-menu > li.active > a {
|
||||
|
||||
color: #fff;
|
||||
background:#3e355a;
|
||||
border-left-color:#8964a9;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.skin-blue .main-header .logo:hover {
|
||||
background:
|
||||
#3e355a;
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar .sidebar-toggle:hover {
|
||||
background-color:
|
||||
#3e355a;
|
||||
}
|
||||
.main-footer {
|
||||
background-color: #1f2234;
|
||||
padding: 15px;
|
||||
color: #fff;
|
||||
border-top: 0px;
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar {
|
||||
background-color: #1f2234;
|
||||
}
|
||||
|
||||
.bg-red, .callout.callout-danger, .alert-danger, .alert-error, .label-danger, .modal-danger .modal-body {
|
||||
background-color:
|
||||
#1f2234 !important;
|
||||
}
|
||||
.alert-danger, .alert-error {
|
||||
|
||||
border-color: #fff;
|
||||
border: 1px solid
|
||||
|
||||
#fff;
|
||||
border-radius: 7px;
|
||||
|
||||
}
|
||||
|
||||
.skin-blue .main-header .navbar .nav > li > a:hover, .skin-blue .main-header .navbar .nav > li > a:active, .skin-blue .main-header .navbar .nav > li > a:focus, .skin-blue .main-header .navbar .nav .open > a, .skin-blue .main-header .navbar .nav .open > a:hover, .skin-blue .main-header .navbar .nav .open > a:focus {
|
||||
color:
|
||||
#f6f6f6;
|
||||
background-color: #3e355a;
|
||||
}
|
||||
.bg-aqua, .callout.callout-info, .alert-info, .label-info, .modal-info .modal-body {
|
||||
background-color:
|
||||
#3e355a !important;
|
||||
}
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
background-color: #333646;
|
||||
border-top: 3px solid #8964a9;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
}
|
||||
.table-striped > tbody > tr:nth-of-type(2n+1) {
|
||||
background-color:
|
||||
#333646;
|
||||
}
|
||||
|
||||
.box-header {
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.box.box-danger {
|
||||
border-top-color: #8964a9;
|
||||
}
|
||||
.box.box-primary {
|
||||
border-top-color: #8964a9;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #8964a9;
|
||||
}
|
||||
.box-header.with-border {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
a:hover, a:active, a:focus {
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
}
|
||||
// .modal.in .modal-dialog{
|
||||
// color:#000;
|
||||
// }
|
||||
|
||||
.form-control {
|
||||
|
||||
background-color:#333646;
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
.box-footer {
|
||||
|
||||
border-top: none;
|
||||
|
||||
background-color:
|
||||
#333646;
|
||||
}
|
||||
.modal-footer {
|
||||
|
||||
border-top: none;
|
||||
|
||||
}
|
||||
.modal-content {
|
||||
|
||||
background-color:
|
||||
#333646;
|
||||
}
|
||||
.modal.in .modal-dialog {
|
||||
|
||||
background-color: #333646;
|
||||
|
||||
}
|
||||
|
||||
.layout-boxed {
|
||||
background: none;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
background-color:
|
||||
#3e355a;
|
||||
}
|
||||
|
||||
.skin-blue .sidebar-menu > li > a:hover, .skin-blue .sidebar-menu > li.active > a {
|
||||
|
||||
background: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body class="skin-blue layout-boxed sidebar-collapse sidebar-open">
|
||||
<div class="wrapper">
|
||||
<header class="main-header">
|
||||
<!-- Logo -->
|
||||
<a href="{{ url_for('core.home') }}" class="logo"><b>LN</b>bits</a>
|
||||
<!-- Header Navbar: style can be found in header.less -->
|
||||
<nav class="navbar navbar-static-top" role="navigation">
|
||||
<!-- Sidebar toggle button-->
|
||||
<a
|
||||
href="#"
|
||||
class="sidebar-toggle"
|
||||
data-toggle="offcanvas"
|
||||
role="button"
|
||||
<qrcode
|
||||
:value="'{{ ticket_id }}'"
|
||||
:options="{width: 340}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
<br />
|
||||
<q-btn @click="printWindow" color="grey" class="q-ml-auto">
|
||||
<q-icon left size="3em" name="print"></q-icon> Print</q-btn
|
||||
>
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
</a>
|
||||
<div class="navbar-custom-menu">
|
||||
<ul class="nav navbar-nav">
|
||||
<!-- Messages: style can be found in dropdown.less-->
|
||||
<li class="dropdown messages-menu">
|
||||
|
||||
{% block messages %}{% endblock %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<aside class="main-sidebar">
|
||||
<!-- sidebar: style can be found in sidebar.less -->
|
||||
<section class="sidebar" style="height: auto;">
|
||||
|
||||
</section>
|
||||
<!-- /.sidebar -->
|
||||
</aside>
|
||||
|
||||
<!-- Right side column. Contains the navbar and content of the page -->
|
||||
<div class="content-wrapper">
|
||||
<!-- Content Header (Page header) -->
|
||||
<section class="content-header">
|
||||
<h1>
|
||||
LNBits Events
|
||||
<small>Lightning powered tickets</small>
|
||||
</h1>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Main content -->
|
||||
<section class="content"><br/><br/>
|
||||
|
||||
<center><h2 style="width:70%;font-size:400%" >Bookmark/Screenshot this page. <br/>It is your ticket!</h2></center>
|
||||
<center> <div style="width:340px;background-color:white;padding:20px"id="qrcode"></div></center>
|
||||
|
||||
|
||||
</section><!-- /.content -->
|
||||
</div><!-- /.content-wrapper -->
|
||||
</div>
|
||||
</body>
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
new QRCode(document.getElementById('qrcode'), {
|
||||
text: "{{ticket}}",
|
||||
width: 300,
|
||||
height: 300,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
</html>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
printWindow: function () {
|
||||
window.print()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,174 +1,55 @@
|
||||
import uuid
|
||||
from quart import g, abort, render_template
|
||||
from datetime import date, datetime
|
||||
|
||||
from flask import jsonify, render_template, request, redirect, url_for
|
||||
from datetime import datetime
|
||||
from lnbits.decorators import check_user_exists, validate_uuids
|
||||
from http import HTTPStatus
|
||||
|
||||
from lnbits.db import open_db, open_ext_db
|
||||
from lnbits.extensions.events import events_ext
|
||||
from .crud import get_ticket, get_event
|
||||
|
||||
|
||||
@events_ext.route("/")
|
||||
def index():
|
||||
"""Main events link page."""
|
||||
usr = request.args.get("usr")
|
||||
@validate_uuids(["usr"], required=True)
|
||||
@check_user_exists()
|
||||
async def index():
|
||||
return await render_template("events/index.html", user=g.user)
|
||||
|
||||
if usr:
|
||||
if not len(usr) > 20:
|
||||
return redirect(url_for("home"))
|
||||
|
||||
# Get all the data
|
||||
with open_db() as db:
|
||||
user_wallets = db.fetchall("SELECT * FROM wallets WHERE user = ?", (usr,))
|
||||
user_ext = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (usr,))
|
||||
user_ext = [v[0] for v in user_ext]
|
||||
|
||||
with open_ext_db("events") as events_ext_db:
|
||||
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE usr = ?", (usr,))
|
||||
|
||||
# If del is selected by user from events page, the event link is to be deleted
|
||||
evdel = request.args.get("del")
|
||||
if evdel:
|
||||
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE uni = ?", (evdel,))
|
||||
events_ext_db.execute("DELETE FROM events WHERE uni = ?", (evdel,))
|
||||
if user_ev[0][9] > 0:
|
||||
events_ext_db.execute("DELETE FROM eventssold WHERE uni = ?", (user_ev[0][12],))
|
||||
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE usr = ?", (usr,))
|
||||
print(user_ext)
|
||||
|
||||
return render_template(
|
||||
"events/index.html", user_wallets=user_wallets, user=usr, user_ext=user_ext, user_ev=user_ev
|
||||
)
|
||||
|
||||
@events_ext.route("/create", methods=["GET", "POST"])
|
||||
def create():
|
||||
"""."""
|
||||
|
||||
data = request.json
|
||||
tit = data["tit"]
|
||||
wal = data["wal"]
|
||||
cldate = data["cldate"]
|
||||
notickets = data["notickets"]
|
||||
prtick = data["prtickets"]
|
||||
usr = data["usr"]
|
||||
descr = data["descr"]
|
||||
wall = wal.split("-")
|
||||
|
||||
# Form validation
|
||||
if (
|
||||
not tit.replace(" ", "").isalnum()
|
||||
or wal == ""
|
||||
or int(notickets) < 0
|
||||
or int(prtick) < 0
|
||||
):
|
||||
return jsonify({"ERROR": "FORM ERROR"}), 401
|
||||
|
||||
# If id that means its a link being edited, delete the record first
|
||||
if "id" in data:
|
||||
unid = data["id"].split("-")
|
||||
uni = unid[1]
|
||||
unireg = unid[2]
|
||||
with open_ext_db("events") as events_ext_db:
|
||||
events_ext_db.execute("DELETE FROM events WHERE uni = ?", (unid[1],))
|
||||
else:
|
||||
uni = uuid.uuid4().hex
|
||||
unireg = uuid.uuid4().hex
|
||||
|
||||
|
||||
with open_db() as dbb:
|
||||
user_wallets = dbb.fetchall("SELECT * FROM wallets WHERE user = ? AND id = ?", (usr, wall[1],))
|
||||
if not user_wallets:
|
||||
return jsonify({"ERROR": "NO WALLET USER"}), 401
|
||||
|
||||
with open_db() as db:
|
||||
user_ext = db.fetchall("SELECT * FROM extensions WHERE user = ?", (usr,))
|
||||
user_ext = [v[0] for v in user_ext]
|
||||
|
||||
# Add to DB
|
||||
with open_ext_db("events") as events_ext_db:
|
||||
events_ext_db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO events
|
||||
(usr, wal, walnme, walinvkey, uni, tit, cldate, notickets, prtick, descr, unireg)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
usr,
|
||||
wall[1],
|
||||
user_wallets[0][1],
|
||||
user_wallets[0][4],
|
||||
uni,
|
||||
tit,
|
||||
cldate,
|
||||
notickets,
|
||||
prtick,
|
||||
descr,
|
||||
unireg,
|
||||
),
|
||||
@events_ext.route("/<event_id>")
|
||||
async def display(event_id):
|
||||
event = get_event(event_id) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
if event.amount_tickets < 1:
|
||||
return await render_template(
|
||||
"events/error.html", event_name=event.name, event_error="Sorry, tickets are sold out :("
|
||||
)
|
||||
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date()
|
||||
if date.today() > datetime_object:
|
||||
return await render_template(
|
||||
"events/error.html", event_name=event.name, event_error="Sorry, ticket closing date has passed :("
|
||||
)
|
||||
|
||||
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE usr = ?", (usr,))
|
||||
|
||||
if not user_ev:
|
||||
return jsonify({"ERROR": "NO WALLET USER"}), 401
|
||||
|
||||
return render_template(
|
||||
"events/index.html", user_wallets=user_wallets, user=usr, user_ext=user_ext, user_ev=user_ev
|
||||
return await render_template(
|
||||
"events/display.html",
|
||||
event_id=event_id,
|
||||
event_name=event.name,
|
||||
event_info=event.info,
|
||||
event_price=event.price_per_ticket,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@events_ext.route("/wave/<wave>/", methods=["GET", "POST"])
|
||||
def wave(wave):
|
||||
"""."""
|
||||
|
||||
with open_ext_db("events") as events_ext_db:
|
||||
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE unireg = ?", (wave,))
|
||||
if not user_ev:
|
||||
return jsonify({"ERROR": "NO RECORD"}), 401
|
||||
|
||||
return render_template(
|
||||
"events/display.html", wave=wave, nme=user_ev[0][6], descr=user_ev[0][11]
|
||||
@events_ext.route("/ticket/<ticket_id>")
|
||||
async def ticket(ticket_id):
|
||||
ticket = get_ticket(ticket_id) or abort(HTTPStatus.NOT_FOUND, "Ticket does not exist.")
|
||||
event = get_event(ticket.event) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
return await render_template(
|
||||
"events/ticket.html", ticket_id=ticket_id, ticket_name=event.name, ticket_info=event.info
|
||||
)
|
||||
|
||||
@events_ext.route("/registration/<wave>", methods=["GET", "POST"])
|
||||
def registration(wave):
|
||||
"""."""
|
||||
with open_ext_db("events") as events_ext_db:
|
||||
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE uni = ?", (wave,))
|
||||
user_ev_sold = events_ext_db.fetchall("SELECT * FROM eventssold WHERE uni = ? AND paid = 1", (user_ev[0][12],))
|
||||
if not user_ev:
|
||||
return jsonify({"ERROR": "NO RECORD"}), 401
|
||||
|
||||
return render_template(
|
||||
"events/registration.html", user_ev=user_ev, user_ev_sold=user_ev_sold
|
||||
)
|
||||
@events_ext.route("/register/<event_id>")
|
||||
async def register(event_id):
|
||||
event = get_event(event_id) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
|
||||
|
||||
@events_ext.route("/ticket/", methods=["GET"])
|
||||
def ticket():
|
||||
"""."""
|
||||
thehash = request.args.get("hash")
|
||||
unireg = request.args.get("unireg")
|
||||
|
||||
#Double check the payment has cleared
|
||||
with open_db() as db:
|
||||
payment = db.fetchall("SELECT * FROM apipayments WHERE payhash = ?", (thehash,))
|
||||
|
||||
if not payment:
|
||||
return jsonify({"status": "ERROR", "reason":"NO RECORD OF PAYMENT"}), 400
|
||||
|
||||
if payment[0][4] == 1:
|
||||
return jsonify({"status": "ERROR", "reason":"NOT PAID"}), 400
|
||||
|
||||
#Update databases
|
||||
with open_ext_db("events") as events_ext_db:
|
||||
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE unireg = ?", (unireg,))
|
||||
updatesold = user_ev[0][9] + 1
|
||||
events_ext_db.execute("UPDATE events SET sold = ? WHERE unireg = ?", (updatesold, unireg,))
|
||||
events_ext_db.execute("UPDATE eventssold SET paid = 1 WHERE hash = ?", (thehash,))
|
||||
eventssold = events_ext_db.fetchall("SELECT * FROM eventssold WHERE hash = ?", (thehash,))
|
||||
if not eventssold:
|
||||
return jsonify({"status": "ERROR", "reason":"NO TICKET RECORD"}), 200
|
||||
|
||||
return render_template(
|
||||
"events/ticket.html", name=eventssold[0][3], ticket=thehash
|
||||
return await render_template(
|
||||
"events/register.html", event_id=event_id, event_name=event.name, wallet_id=event.wallet
|
||||
)
|
||||
|
||||
@@ -1,65 +1,186 @@
|
||||
import uuid
|
||||
import json
|
||||
import requests
|
||||
from quart import g, jsonify, request
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import jsonify, request, url_for
|
||||
from datetime import datetime
|
||||
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 lnbits.db import open_db, open_ext_db
|
||||
from lnbits.extensions.events import events_ext
|
||||
|
||||
@events_ext.route("/api/v1/getticket/", methods=["GET","POST"])
|
||||
def api_getticket():
|
||||
"""."""
|
||||
|
||||
data = request.json
|
||||
unireg = data["unireg"]
|
||||
name = data["name"]
|
||||
email = request.args.get("ema")
|
||||
|
||||
with open_ext_db("events") as events_ext_db:
|
||||
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE unireg = ?", (unireg,))
|
||||
from .crud import (
|
||||
create_ticket,
|
||||
update_ticket,
|
||||
get_ticket,
|
||||
get_tickets,
|
||||
delete_ticket,
|
||||
create_event,
|
||||
update_event,
|
||||
get_event,
|
||||
get_events,
|
||||
delete_event,
|
||||
get_event_tickets,
|
||||
reg_ticket,
|
||||
)
|
||||
|
||||
|
||||
header = {"Content-Type": "application/json", "Grpc-Metadata-macaroon": user_ev[0][4]}
|
||||
data = {"value": str(user_ev[0][10]), "memo": user_ev[0][6]}
|
||||
print(url_for("api_invoices", _external=True))
|
||||
r = requests.post(url=url_for("api_invoices", _external=True), headers=header, data=json.dumps(data))
|
||||
r_json = r.json()
|
||||
#########Events##########
|
||||
|
||||
if "ERROR" in r_json:
|
||||
return jsonify({"status": "ERROR", "reason": r_json["ERROR"]}), 400
|
||||
|
||||
events_ext_db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO eventssold
|
||||
(uni, email, name, hash)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
unireg,
|
||||
email,
|
||||
name,
|
||||
r_json["payment_hash"]
|
||||
),
|
||||
@events_ext.route("/api/v1/events", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_events():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return jsonify([event._asdict() for event in get_events(wallet_ids)]), HTTPStatus.OK
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/events", methods=["POST"])
|
||||
@events_ext.route("/api/v1/events/<event_id>", methods=["PUT"])
|
||||
@api_check_wallet_key("invoice")
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"wallet": {"type": "string", "empty": False, "required": True},
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"info": {"type": "string", "min": 0, "required": True},
|
||||
"closing_date": {"type": "string", "empty": False, "required": True},
|
||||
"event_start_date": {"type": "string", "empty": False, "required": True},
|
||||
"event_end_date": {"type": "string", "empty": False, "required": True},
|
||||
"amount_tickets": {"type": "integer", "min": 0, "required": True},
|
||||
"price_per_ticket": {"type": "integer", "min": 0, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_event_create(event_id=None):
|
||||
if event_id:
|
||||
event = get_event(event_id)
|
||||
print(g.data)
|
||||
|
||||
if not event:
|
||||
return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if event.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
event = update_event(event_id, **g.data)
|
||||
else:
|
||||
event = create_event(**g.data)
|
||||
print(event)
|
||||
return jsonify(event._asdict()), HTTPStatus.CREATED
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/events/<event_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_form_delete(event_id):
|
||||
event = get_event(event_id)
|
||||
|
||||
if not event:
|
||||
return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if event.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_event(event_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
#########Tickets##########
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/tickets", methods=["GET"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_tickets():
|
||||
wallet_ids = [g.wallet.id]
|
||||
|
||||
if "all_wallets" in request.args:
|
||||
wallet_ids = get_user(g.wallet.user).wallet_ids
|
||||
|
||||
return jsonify([ticket._asdict() for ticket in get_tickets(wallet_ids)]), HTTPStatus.OK
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/tickets/<event_id>/<sats>", methods=["POST"])
|
||||
@api_validate_post_request(
|
||||
schema={
|
||||
"name": {"type": "string", "empty": False, "required": True},
|
||||
"email": {"type": "string", "empty": False, "required": True},
|
||||
}
|
||||
)
|
||||
async def api_ticket_make_ticket(event_id, sats):
|
||||
event = get_event(event_id)
|
||||
if not event:
|
||||
return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND
|
||||
try:
|
||||
payment_hash, payment_request = create_invoice(
|
||||
wallet_id=event.wallet, amount=int(sats), memo=f"{event_id}", extra={"tag": "events"}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
return jsonify({"status": "TRUE", "pay_req": r_json["pay_req"], "payment_hash": r_json["payment_hash"]}), 200
|
||||
ticket = create_ticket(payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data)
|
||||
|
||||
if not ticket:
|
||||
return jsonify({"message": "Event could not be fetched."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
return jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), HTTPStatus.OK
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/checkticket/", methods=["GET"])
|
||||
def api_checkticket():
|
||||
"""."""
|
||||
thehash = request.args.get("thehash")
|
||||
#Check databases
|
||||
with open_ext_db("events") as events_ext_db:
|
||||
eventssold = events_ext_db.fetchall("SELECT * FROM eventssold WHERE hash = ?", (thehash,))
|
||||
if not eventssold:
|
||||
return jsonify({"status": "ERROR", "reason":"NO TICKET RECORD"}), 200
|
||||
if eventssold[0][4] == 0:
|
||||
return jsonify({"status": "ERROR", "reason":"NOT PAID"}), 200
|
||||
@events_ext.route("/api/v1/tickets/<payment_hash>", methods=["GET"])
|
||||
async def api_ticket_send_ticket(payment_hash):
|
||||
ticket = get_ticket(payment_hash)
|
||||
try:
|
||||
is_paid = not check_invoice_status(ticket.wallet, payment_hash).pending
|
||||
except Exception:
|
||||
return jsonify({"message": "Not paid."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
with open_ext_db("events") as events_ext_db:
|
||||
events_ext_db.execute("UPDATE eventssold SET reg = 1 WHERE hash = ?", (thehash,))
|
||||
if is_paid:
|
||||
wallet = get_wallet(ticket.wallet)
|
||||
payment = wallet.get_payment(payment_hash)
|
||||
payment.set_pending(False)
|
||||
ticket = update_ticket(paid=True, payment_hash=payment_hash)
|
||||
|
||||
return jsonify({"status": "TRUE", "name": eventssold[0][3]}), 200
|
||||
return jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK
|
||||
|
||||
return jsonify({"paid": False}), HTTPStatus.OK
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/tickets/<ticket_id>", methods=["DELETE"])
|
||||
@api_check_wallet_key("invoice")
|
||||
async def api_ticket_delete(ticket_id):
|
||||
ticket = get_ticket(ticket_id)
|
||||
|
||||
if not ticket:
|
||||
return jsonify({"message": "Ticket does not exist."}), HTTPStatus.NOT_FOUND
|
||||
|
||||
if ticket.wallet != g.wallet.id:
|
||||
return jsonify({"message": "Not your ticket."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
delete_ticket(ticket_id)
|
||||
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
#########EventTickets##########
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/eventtickets/<wallet_id>/<event_id>", methods=["GET"])
|
||||
async def api_event_tickets(wallet_id, event_id):
|
||||
|
||||
return (
|
||||
jsonify([ticket._asdict() for ticket in get_event_tickets(wallet_id=wallet_id, event_id=event_id)]),
|
||||
HTTPStatus.OK,
|
||||
)
|
||||
|
||||
|
||||
@events_ext.route("/api/v1/register/ticket/<ticket_id>", methods=["GET"])
|
||||
async def api_event_register_ticket(ticket_id):
|
||||
|
||||
ticket = get_ticket(ticket_id)
|
||||
|
||||
if not ticket:
|
||||
return jsonify({"message": "Ticket does not exist."}), HTTPStatus.FORBIDDEN
|
||||
|
||||
if ticket.registered == True:
|
||||
return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN
|
||||
|
||||
return jsonify([ticket._asdict() for ticket in reg_ticket(ticket_id)]), HTTPStatus.OK
|
||||
|
||||
@@ -8,4 +8,4 @@ Try to include an image
|
||||
|
||||
<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 "Grpc-Metadata-macaroon: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from flask import Blueprint
|
||||
from quart import Blueprint
|
||||
|
||||
|
||||
example_ext = Blueprint("example", __name__, static_folder="static", template_folder="templates")
|
||||
example_ext: Blueprint = Blueprint("example", __name__, static_folder="static", template_folder="templates")
|
||||
|
||||
|
||||
from .views_api import * # noqa
|
||||
|
||||