Compare commits

..
176 Commits
Author SHA1 Message Date
Eneko Illarramendiandfiatjaf 098089af75 PREFER_SECURE_URLS, get rid of FORCE_HTTPS hacks. 2020-09-24 13:08:59 -03:00
fiatjaf 39cd96594e pass appropriate parameters to create_payment on lnurlwallet. 2020-09-24 13:09:02 -03:00
fiatjafandGitHub dbabf937c4 Merge pull request #93 from lnbits/quart 2020-09-17 22:51:19 -03:00
fiatjaf cd8a447ba0 pytest quibbles and dev dependencies. 2020-09-17 22:41:30 -03:00
fiatjaf ef0bdf8363 mypy quibbles. 2020-09-16 20:24:17 -03:00
fiatjaf 75047c5bc3 fix static extensions js url. 2020-09-16 20:21:42 -03:00
fiatjaf 2b38207100 mimic x_proto, x_host params from old ProxyFix. 2020-09-15 17:19:34 -03:00
fiatjaf 5513b34a66 change 1 to true on env vars because that is supported now. 2020-09-15 17:19:11 -03:00
fiatjaf 6928f431a7 poor man's flask-assets. 2020-09-15 15:54:05 -03:00
fiatjaf 3a0762ff82 proxy fix (x-forwarded-...). 2020-09-14 23:56:25 -03:00
fiatjaf 42c6620a07 quart run settings and hypercorn on docker/Procfile. 2020-09-14 23:28:14 -03:00
fiatjaf f01028eac7 migrate from flask to quart.
also remove all flaskiness from static file serving.
and reference all vendored scripts on the base tempĺate for simplicity.
2020-09-14 16:03:25 -03:00
fiatjaf f452b9c00d withdraw: replace location.hostname with location.host.
fixes https://github.com/lnbits/lnbits/issues/88
2020-09-14 15:46:25 -03:00
fiatjaf 1e2dd412c7 lnurlp: remove wrong /pay/ segments from docs. 2020-09-14 09:35:58 -03:00
fiatjaf e6ad42dbe1 update prettier to 2.1.1 2020-09-14 09:14:51 -03:00
fiatjaf 1faf0b152c remove annoying print(). 2020-09-13 21:48:46 -03:00
fiatjafandfiatjaf f4fffc19aa lndhub extension frontend. 2020-09-12 21:55:13 -03:00
fiatjafandfiatjaf 6210305791 checking pending invoices and payments from lndhub interface. 2020-09-12 21:55:13 -03:00
fiatjafandfiatjaf ce038193cf lndhub extension backend. 2020-09-12 21:55:13 -03:00
fiatjaf 5ac91ee2cf db commits at the right places when sending payments.
fixing vulnerabilities introduced in https://github.com/lnbits/lnbits/pull/90
2020-09-11 21:25:44 -03:00
fiatjafandfiatjaf 4855e2cd3d make every request a database transaction. 2020-09-11 14:17:09 -03:00
fiatjafandfiatjaf 487f082eb2 take extra care on check_internal() 2020-09-11 14:17:09 -03:00
frennkieandGitHub a8e1aa3154 Consider port number on lnurlp URL
fix #86
2020-09-09 21:43:34 -03:00
ArcandGitHub afb2cc4339 Update .env.example 2020-09-08 09:55:31 +01:00
ArcandGitHub 6b94abcfb9 Moved install guide to more prominent position 2020-09-08 09:30:23 +01:00
supertestnetandGitHub bd48bdc04d Update .env.example due to recent changes (#91)
The name of the flask app has changed and the word "all" has been eliminated as unnecessary because allowing anyone to use lnbits is the default.
2020-09-07 16:05:50 +02:00
fiatjaf 130beaf38a fix undeclared ok error. 2020-09-06 23:39:46 -03:00
fiatjaf ab6229806c fix: column is called hash, not payment_hash. 2020-09-06 22:15:29 -03:00
Eneko Illarramendiandfiatjaf d62031b068 chore: let's just call it flask_migrate
I think it should stay, because it can come handy for some deployment methods.
2020-09-06 21:06:01 -03:00
Eneko Illarramendiandfiatjaf 1bc5e144d3 refactor: clean up __init__ file following some Flask conventions
Flask extensions are loaded in a way that makes them easily reusable by blueprints.
In this commit we are also adding `environs` to manage .env and settings:
breaking changes!

- FLASK_APP=lnbits.app
- LNBITS_ALLOWED_USERS needs to be empty now to allow all users (NOT "all")
2020-09-06 21:06:01 -03:00
fiatjafandfiatjaf ffa3c3f6a6 python -m lnbits 2020-09-04 21:33:30 -03:00
fiatjafandfiatjaf b8de30423f remove walrus operator because python3.7 2020-09-04 21:33:30 -03:00
fiatjafandfiatjaf c965bca41d migration tracking. 2020-09-04 21:33:30 -03:00
Eneko Illarramendi f238b3d1ef fix: TypedDict import 2020-09-04 21:24:30 +02:00
Eneko Illarramendi 6b33f150c6 fix: revert default python version to 3.7 in Pipfile, closes #85
But tests will be run on both 3.7 and 3.8 as we want to support >= 3.7
2020-09-04 21:19:06 +02:00
fiatjaf 8b7028d728 add VoidWallet and make it the default. 2020-09-03 21:43:32 -03:00
Eneko Illarramendi 23cfe0d417 chore: update github workflows 2020-09-03 23:10:41 +02:00
Eneko Illarramendi a651f747ac chore: remove gunicorn and gevent from minimum requirements
It is now up to the user to install these (or other) for production deployment.
More info: https://flask.palletsprojects.com/en/1.1.x/tutorial/deploy/#run-with-a-production-server
2020-09-03 19:38:01 +02:00
fiatjaf dcd3f80974 fix: invoice.payee is None by default. 2020-09-03 08:53:17 -03:00
fiatjafandGitHub 6513908a8d Merge pull request #81 from lnbits/internalpaymentsmegachanges 2020-09-03 00:29:20 -03:00
fiatjaf 753e9c5b15 make linting workflow reflect what is done locally (Makefile). 2020-09-02 23:16:23 -03:00
fiatjaf dc3d96c6a8 fix many mypy complaints, specially on bolt11.py 2020-09-02 22:21:56 -03:00
fiatjaf ce28db76c9 add a dialog with payment details for each payment.
for outgoing payments this needs a preimage to be good,
but we don't have it yet because we don't get it from
backends.
2020-09-02 19:19:19 -03:00
fiatjaf 197af922d0 use {"tag": ext} for extension-related payments. 2020-09-02 12:44:54 -03:00
fiatjaf 4447a48724 deleting expired invoices based on their actual expiry date.
also fixes a possible bug that could have caused pending outgoing
payments to be deleted and affecting the balance. probably never happened.
2020-09-02 11:10:48 -03:00
fiatjaf d2650d6e2c don't try to check payments that start with temp_ or internal_. 2020-09-02 11:10:48 -03:00
fiatjaf b56877d470 make db.py context manager operate in a transaction. 2020-09-02 11:10:48 -03:00
fiatjaf bf3c44b3c4 extra fields on apipayments + index payments by payment_hash 2020-09-02 11:10:48 -03:00
arcbtcandfiatjaf c96b22664e internal payments. 2020-09-02 11:10:48 -03:00
Eneko Illarramendi d4f957a5c8 chore: update js vendors 2020-09-02 14:39:20 +02:00
Eneko Illarramendi 0d0e00e699 chore: update Pipfile 2020-09-02 14:31:44 +02:00
fiatjaf b4a6b45645 only check pending payments on "check_pending". 2020-08-31 22:55:19 -03:00
fiatjaf fadddc995a get_wallet_payments with more fine-grained, explicit filters. 2020-08-31 22:16:19 -03:00
fiatjaf d09e624eb6 fix: wallet.balance computed property self-reference. 2020-08-31 16:19:27 -03:00
fiatjaf 5d523519c3 bring back code mysteriously deleted in 79d5befe0c
fixes https://github.com/lnbits/lnbits/issues/73
2020-08-31 00:09:34 -03:00
fiatjaf 68b0adfe66 fix: description_hash as an optional param to create_invoice.
fixes https://github.com/lnbits/lnbits/issues/74
2020-08-30 23:54:50 -03:00
fiatjaf 660d56d400 run black on everything. 2020-08-30 23:19:43 -03:00
fiatjaf 2cecaa229b bolt11.py now supports everything. 2020-08-30 17:40:28 -03:00
fiatjaf ac20e06dc3 fix: correct db.cursor() usage on lnurlp. 2020-08-29 22:33:01 -03:00
fiatjaf 79d5befe0c mypy on Makefile and apply prettier. 2020-08-29 14:10:26 -03:00
fiatjafandGitHub 479760c5a5 Merge pull request #43 from fiatjaf/lnurlpayserver
description_hash support, spark backend and lnurlp extension.
2020-08-29 14:02:59 -03:00
fiatjaf 3ce1e72e10 lnurlp lnurl explanation slightly different from lnurlw. 2020-08-29 14:06:07 -03:00
fiatjaf 1e4f39954e fix: spark create_invoice error case. 2020-08-29 13:48:16 -03:00
fiatjaf 01b19add79 format: prettier lnurlp files. 2020-08-29 13:41:59 -03:00
fiatjaf 96b80fb3fc lnurlp: store description as memo. 2020-08-29 13:11:19 -03:00
fiatjaf 3645e0ac38 fix: lnpay invoices with description_hash. 2020-08-29 13:04:11 -03:00
fiatjaf 0c7284be31 description_hash fix. 2020-08-29 12:23:17 -03:00
fiatjaf 0de08dda0b add spark backend and fix c-lightning. 2020-08-29 12:23:01 -03:00
fiatjaf a79ec8f4a4 lnurlp extension. 2020-08-28 23:03:12 -03:00
arcbtc 6cf488ea4f Fixed lnurlw bugs 2020-08-19 08:49:52 +01:00
Eneko Illarramendi e01de0eae5 chore: update requirements (but lock gevents)
`gevents` has been locked to avoid myNode installation problems.
2020-08-18 08:43:04 +02:00
ArcandGitHub 5650731bce Update index.html 2020-08-16 23:25:54 +01:00
ArcandGitHub 53d65c0258 Update index.html 2020-08-16 23:22:32 +01:00
ArcandGitHub c4db2d5c28 Update index.html 2020-08-16 23:21:44 +01:00
arcbtc b97595b06d Added "quick vouchers" option to withdraw ext 2020-08-16 22:46:06 +01:00
arcbtc dd2a282158 Fixed migration bug 2020-08-13 23:43:42 +01:00
arcbtc 3c7e4a3eaf Fixed events migration 2020-08-13 22:01:33 +01:00
arcbtc 89c31139f8 Merge remote-tracking branch 'origin/master' 2020-08-13 21:50:16 +01:00
arcbtc 2d580a0d0e Fixed lntickets migration 2020-08-13 21:49:50 +01:00
Eneko Illarramendi 0497c603c0 fix: lnurl bugfix
This is not using `$ pipenv update` because apparently other package updates break mynode installations, see #65
2020-08-13 22:19:36 +02:00
Eneko Illarramendi 5831fe2ab6 Revert "fix: requirements update, including lnurl bugfix"
This reverts commit bc984ab0db.
2020-08-13 22:16:16 +02:00
ArcandGitHub dfdb890751 Merge pull request #62 from losnappas/patch-1
Update extensions.md
2020-08-10 19:49:18 +01:00
arcbtc 3735fe7aa1 Merge remote-tracking branch 'origin/master' 2020-08-10 19:45:20 +01:00
arcbtc 97a13526d9 Made print outs bigger 2020-08-10 19:44:55 +01:00
ArcandGitHub be04623b9d Update README.md 2020-08-10 19:27:37 +01:00
arcbtc c56784340e LNtickets now works 2020-08-10 19:08:44 +01:00
arcbtc 0603e525ff Fixed LNticket extension 2020-08-10 19:06:56 +01:00
arcbtc 410e96771e Events extension works
Little bug fix
2020-08-10 15:39:04 +01:00
arcbtc 784a2dd876 Events extension now works 2020-08-10 15:27:30 +01:00
ArcandGitHub bc930ab606 Update README.md 2020-08-10 11:55:15 +01:00
ArcandGitHub f518e350a6 Update README.md 2020-08-10 11:54:28 +01:00
ArcandGitHub 8055e6a73c Update README.md 2020-08-08 21:17:07 +01:00
ArcandGitHub 6b2061f107 Update README.md 2020-08-08 21:16:46 +01:00
ArcandGitHub ce2f764432 Update README.md
Added some props to our forebears
2020-08-08 21:16:01 +01:00
losnappasandGitHub efe4b91877 Update extensions.md
Add a skeleton, better than nothing.
2020-08-07 07:37:41 +00:00
ArcandGitHub 55a9dc749a Merge pull request #56 from frennkie/fix-withdraw
fix https being stripped from QRCodes when showing print view
2020-08-04 21:19:52 +01:00
Eneko Illarramendi bc984ab0db fix: requirements update, including lnurl bugfix 2020-08-04 19:22:24 +02:00
Robert Habermann 1e36a8a393 add missing protocol (to ensure http - or more likely - https://) 2020-08-02 20:01:07 +02:00
benarc c17ac32c86 Fixed LNURLw migration 2020-07-22 20:17:37 +01:00
benarc c73fbe1b27 Fixed unique LNURLw for print 2020-07-20 22:08:54 +01:00
benarc 2ddc23de04 LNURLw fix 2020-07-20 21:32:55 +01:00
benarc 294beb2fca Updated Withdraw extension to handle unique facets 2020-07-20 21:03:23 +01:00
ArcandGitHub 6c286cd265 Merge pull request #51 from frennkie/patch-1
Update index.js to include port in links
2020-07-16 12:17:58 +01:00
frennkieandGitHub 68e41789ce Update index.js
`location.host` contains the hostname and the port while `location.hostname` is only the hostname
2020-07-12 16:24:18 +02:00
EnekoandGitHub e746dcd787 feat: enable code scanning 2020-07-06 18:51:02 +02:00
fiatjaf 24dc6e5415 fix validation rules so description_hash works. 2020-07-04 22:28:08 -03:00
fiatjaf bc27293315 support description_hash across all APIs. 2020-07-03 20:04:04 -03:00
ArcandGitHub 61f736878c Update installation.md 2020-06-22 15:15:27 +01:00
ArcandGitHub f51d44e99b Update installation.md 2020-06-22 15:14:51 +01:00
benarc 95b0ba536b Prettier 2020-06-15 16:53:34 +01:00
benarc 5e81c64831 Usermanager extension update 2020-06-15 16:35:30 +01:00
benarc d56c2627f4 Endpoint for updating user extensions vie usermanager 2020-06-15 16:30:24 +01:00
Dennis ReimannandGitHub c3281399d2 Improve setup instructions and README
- README cleanups and link to documentation
- Improve dev setup instructions
- Fix js linting errors
- Move local setup instructions to installation docs
- Project link fix, closes #42
- Notes about frontend
- Clarify different LND backends
2020-06-12 20:58:37 +02:00
Eneko Illarramendi 8ea72c9934 feat: add CORS headers 2020-05-18 23:33:07 +02:00
Eneko Illarramendi aed2414f3a fix: show better errors when a LNURL-withdraw wallet cannot be created, closes #39 2020-05-18 06:33:02 +02:00
benarc 76f17d8594 Removed API docs for Support Tickets 2020-05-11 13:21:03 +01:00
benarc 85e617aae9 Updated redme 2020-05-11 13:14:25 +01:00
benarc 68d94a43d0 Support Ticket name change 2020-05-11 13:11:36 +01:00
benarc 29dc33dd71 Merge remote-tracking branch 'origin/master' 2020-05-11 12:35:29 +01:00
benarc a54bff6640 Changed extension name to Support Tickets 2020-05-11 12:34:35 +01:00
benarc d2874a90e9 Working Events extension 2020-05-11 12:34:35 +01:00
benarc c099885bbb Changed extension name to Support Tickets 2020-05-11 12:34:16 +01:00
benarc b296c38795 Working events extension 2020-05-11 12:16:26 +01:00
benarc 9af4161679 Redirect feedback 2020-05-10 18:46:58 +01:00
benarc 76510b7892 Events extension mostly functional
Can register attendees via camera
2020-05-10 18:10:45 +01:00
benarc ea3b858695 Replaced events extension with almost working vuejs one 2020-05-09 22:27:11 +01:00
Eneko Illarramendi 41277e6931 fix(paywall): disable create button when an invoice already exists 2020-05-09 22:52:30 +02:00
Eneko Illarramendi 8add56a24c feat: allow limiting the use of LNbits to some user uuids
The `LNBITS_ALLOWED_USERS` env var can be used to limit access
to the application to some uuids only. "all" gives open access.
UUIDs should be comma separated.
2020-05-09 22:30:33 +02:00
ArcandGitHub e768e4b075 Update views_api.py 2020-05-09 18:07:31 +01:00
ArcandGitHub a40823de43 Update lntxbot.py 2020-05-09 17:29:24 +01:00
EnekoandGitHub 171e639109 chore: add paywall address to github funding 2020-05-09 15:23:22 +02:00
ArcandGitHub 6f4cac0c41 Update index.html 2020-05-08 22:19:43 +01:00
ArcandGitHub 97dff38af4 Update README.md 2020-05-08 22:17:43 +01:00
ArcandGitHub 13f01dfbe6 Add files via upload 2020-05-08 21:03:18 +01:00
Eneko Illarramendi 14d61eaf56 feat(withdraw): type casting 2020-05-08 21:12:31 +02:00
Eneko Illarramendi 63b9741c14 feat(tpos): type casting 2020-05-08 21:11:59 +02:00
Eneko Illarramendi 22451ca806 fix: allow empty values (nullable) 2020-05-08 21:10:04 +02:00
Eneko Illarramendi e73a508011 feat(paywall): improved extension
- make remember cookie optional
- improve database
- improve type casting
2020-05-08 21:05:32 +02:00
ArcandGitHub 2fac47c05a Update _api_docs.html 2020-05-05 20:53:13 +01:00
ArcandGitHub b8dd151043 Updated api docs to include listing specific LNURL 2020-05-05 20:07:54 +01:00
Eneko IllarramendiandGitHub 42d307adf9 Merge pull request #38 from fiatjaf/adminkey-to-inkey
allow inkey endpoints to be accessed with adminkeys.
2020-05-05 11:09:02 +02:00
fiatjaf c3ce18c8d9 allow inkey endpoints to be accessed with adminkeys. 2020-05-05 05:46:05 -03:00
Eneko IllarramendiandGitHub d914c3f701 Merge pull request #36 from sgeisler/2020-05-domain-https
Use https in base url to avoid mixed content
2020-05-04 18:34:22 +02:00
Sebastian Geisler 52aff1128e use https base url to avoid mixed content 2020-05-04 18:25:16 +02:00
Eneko Illarramendi 9eb253fe55 fix: typo 2020-05-04 17:34:53 +02:00
Eneko Illarramendi 35fb524e1e Update CNAME 2020-05-04 17:31:53 +02:00
Eneko Illarramendi 01c8e8a23f Create CNAME 2020-05-04 17:27:49 +02:00
Eneko Illarramendi b37af0c600 feat: use github pages and jekyll for docs 2020-05-04 17:20:36 +02:00
Eneko Illarramendi a61d346669 chore: update js vendors 2020-05-04 15:33:39 +02:00
Eneko IllarramendiandGitHub 8530413537 Merge pull request #35 from sgeisler/2020-05-js-formatting-ci
Add JS formatting enforcenemt to CI
2020-05-04 14:56:43 +02:00
Sebastian Geisler 3a6034e8f6 Add prettier JS formatting enforcenemt to CI 2020-05-04 11:40:44 +02:00
Eneko IllarramendiandGitHub d9fe80d41b Merge pull request #34 from fiatjaf/prettier
apply prettier to everything.
2020-05-03 23:40:54 +02:00
fiatjaf 4730500ed7 apply prettier to everything. 2020-05-03 13:55:17 -03:00
Eneko Illarramendi 083f7a0a8d chore: use standard library's HTTP status codes 2020-05-03 15:57:05 +02:00
ArcandGitHub 3a1167c892 Rename config.json.example to config.json 2020-05-02 20:17:33 +01:00
Eneko IllarramendiandGitHub 236bcac263 Merge pull request #26 from eillarra/less_requirements
refactor: remove wallet only dependencies from core
2020-05-02 19:25:47 +02:00
Eneko Illarramendi 2c5e539795 refactor: make some wallet dependencies optional 2020-05-02 19:16:10 +02:00
ArcandGitHub 5553ed82f0 Update index.html 2020-05-01 09:48:00 +01:00
ArcandGitHub 9fadb68fed Update index.html 2020-05-01 06:28:45 +01:00
ArcandGitHub d496869572 Update _api_docs.html 2020-05-01 06:12:55 +01:00
ArcandGitHub 9f33ed49e5 Update README.md 2020-05-01 06:12:20 +01:00
ArcandGitHub ea480866ea Update README.md 2020-05-01 06:10:44 +01:00
ArcandGitHub 690b33e096 Rename config.json to config.json.example 2020-05-01 06:10:16 +01:00
ArcandGitHub d72678972d Add files via upload 2020-05-01 06:09:51 +01:00
Eneko Illarramendi 8dfdd15adb fix: self host icon fonts 2020-04-29 13:24:49 +02:00
Eneko Illarramendi 8442f277c4 tests: only use github for CI 2020-04-28 23:09:59 +02:00
Eneko IllarramendiandGitHub 8f76da66e8 Merge pull request #25 from sgeisler/2020-04-ci-lnpay
Use LNPay backend for CI
2020-04-28 08:49:30 +02:00
Sebastian Geisler f7ca28db34 Use LNPay backend for CI
As a quickfix to make travis happy we'll
use an empty LNPay wallet as backend in tests.
This should be replaced with a mock backend
later on.
2020-04-28 00:00:56 +02:00
Eneko IllarramendiandGitHub c951577962 Merge pull request #19 from frennkie/allow-lightning-scheme
Allow lightning scheme
2020-04-28 00:00:19 +02:00
Robert Habermann 0847a1c933 add lightning scheme check 2020-04-27 23:13:42 +02:00
ArcandGitHub f731bd766e Update and rename config.json to config.json.example 2020-04-27 22:01:21 +01:00
ArcandGitHub 59819e2875 Update _api_docs.html 2020-04-27 21:08:31 +01:00
ArcandGitHub e7184346ab Update _api_docs.html 2020-04-27 21:08:17 +01:00
Eneko Illarramendi 178fe65fb2 test: configure Travis for CI 2020-04-27 19:39:00 +02:00
ArcandGitHub 1c8beba80b Added warning to use https or TOR for lnurl 2020-04-27 17:43:29 +01:00
ArcandGitHub 1516a14a0d Added warning to use https or TOR for lnurl 2020-04-27 17:43:15 +01:00
219 changed files with 18622 additions and 9525 deletions
+11 -6
View File
@@ -1,14 +1,19 @@
FLASK_APP=lnbits QUART_APP=lnbits.app
FLASK_ENV=development QUART_ENV=development
QUART_DEBUG=true
HOST=127.0.0.1
PORT=5000
LNBITS_SITE_TITLE=LNbits LNBITS_SITE_TITLE=LNbits
LNBITS_ALLOWED_USERS=""
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
LNBITS_DATA_FOLDER="/your_custom_data_folder" LNBITS_DATA_FOLDER="/your_custom_data_folder" #IMPORTANT! i.e. "/home/satoshi/lnbits/lnbits/data"
LNBITS_DISABLED_EXTENSIONS="amilk,events" LNBITS_DISABLED_EXTENSIONS="amilk"
LNBITS_FORCE_HTTPS=1 LNBITS_FORCE_HTTPS=true
LNBITS_SERVICE_FEE="0.0" LNBITS_SERVICE_FEE="0.0"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet, CLightningWallet, LnbitsWallet # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC), LndRestWallet, CLightningWallet, LnbitsWallet
LNBITS_BACKEND_WALLET_CLASS=LntxbotWallet LNBITS_BACKEND_WALLET_CLASS=LntxbotWallet
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc" CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
-2
View File
@@ -1,2 +0,0 @@
lnbits/static/vendor/** linguist-vendored
lnbits/extensions/**/static/vendor/** linguist-vendored
+1
View File
@@ -0,0 +1 @@
custom: https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK
+28
View File
@@ -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
+23
View File
@@ -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
+2 -3
View File
@@ -1,13 +1,12 @@
name: mypy type checker name: mypy
on: [push, pull_request] on: [push, pull_request]
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v1 - uses: actions/checkout@v1
- uses: jpetrucciani/mypy-check@master - uses: jpetrucciani/mypy-check@master
with: with:
path: 'lnbits' path: lnbits
+36
View File
@@ -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
+4
View File
@@ -21,9 +21,13 @@ test-reports
*.pyc *.pyc
*.env *.env
.env .env
.venv
venv venv
*.sqlite3 *.sqlite3
.pyre* .pyre*
__bundle__ __bundle__
node_modules
lnbits/static/bundle.*
+12
View File
@@ -0,0 +1,12 @@
{
"semi": false,
"arrowParens": "avoid",
"insertPragma": false,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"jsxBracketSameLine": false,
"bracketSpacing": false
}
-29
View File
@@ -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
+2 -1
View File
@@ -1,8 +1,9 @@
FROM python:3.7 FROM python:3.7-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt /app/ COPY requirements.txt /app/
RUN pip install --no-cache-dir -q -r requirements.txt RUN pip install --no-cache-dir -q -r requirements.txt
RUN pip install --no-cache-dir -q hypercorn
COPY . /app COPY . /app
EXPOSE 5000 EXPOSE 5000
+23
View File
@@ -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
+10 -12
View File
@@ -9,23 +9,21 @@ python_version = "3.7"
[packages] [packages]
bitstring = "*" bitstring = "*"
cerberus = "*" cerberus = "*"
lnd-grpc = "*" ecdsa = "*"
environs = "*"
lnurl = "*" lnurl = "*"
flask = "*"
flask-assets = "*"
flask-compress = "*"
flask-talisman = "*"
gevent = "*"
gunicorn = "*"
pylightning = "*"
pyscss = "*" pyscss = "*"
requests = "*" requests = "*"
shortuuid = "*" shortuuid = "*"
quart = "*"
quart-cors = "*"
quart-compress = "*"
secure = "*"
typing-extensions = "*"
[dev-packages] [dev-packages]
black = "==19.10b0" black = "==20.8b1"
flake8 = "*"
flake8-mypy = "*"
pytest = "*" pytest = "*"
pytest-cov = "*" pytest-cov = "*"
pytest-sugar = "*" pytest-asyncio = "*"
mypy = "==0.761"
Generated
+328 -429
View File
@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "1c40bedbf1f2a9e9bccd443b57bd6dda496361df8712ddda5efcec0d00cab9f1" "sha256": "775e2ea809508c83df4696bba3a38a03e288cba22f0a7f562120230f40351ab9"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -16,21 +16,65 @@
] ]
}, },
"default": { "default": {
"aiofiles": {
"hashes": [
"sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb",
"sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af"
],
"version": "==0.5.0"
},
"bech32": { "bech32": {
"hashes": [ "hashes": [
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899", "sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
"sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981" "sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981"
], ],
"markers": "python_version >= '3.5'",
"version": "==1.2.0" "version": "==1.2.0"
}, },
"bitstring": { "bitstring": {
"hashes": [ "hashes": [
"sha256:7b60b0c300d0d3d0a24ec84abfda4b0eaed3dc56dc90f6cbfe497166c9ad8443", "sha256:fdf3eb72b229d2864fb507f8f42b1b2c57af7ce5fec035972f9566de440a864a"
"sha256:c97a8e2a136e99b523b27da420736ae5cb68f83519d633794a6a11192f69f8bf",
"sha256:e392819965e7e0246e3cf6a51d5a54e731890ae03ebbfa3cd0e4f74909072096"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.1.6" "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": { "cerberus": {
"hashes": [ "hashes": [
@@ -41,10 +85,10 @@
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
"sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
], ],
"version": "==2020.4.5.1" "version": "==2020.6.20"
}, },
"chardet": { "chardet": {
"hashes": [ "hashes": [
@@ -55,198 +99,78 @@
}, },
"click": { "click": {
"hashes": [ "hashes": [
"sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc", "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a" "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
], ],
"version": "==7.1.1" "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==7.1.2"
}, },
"flask": { "ecdsa": {
"hashes": [ "hashes": [
"sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060", "sha256:494c6a853e9ed2e9be33d160b41d47afc50a6629b993d2b9c5ad7bb226add892",
"sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557" "sha256:ca359c971594dceebf334f3d623dae43163ab161c7d09f28cae70a86df26eb7a"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.1.2" "version": "==0.16.0"
}, },
"flask-assets": { "environs": {
"hashes": [ "hashes": [
"sha256:1dfdea35e40744d46aada72831f7613d67bf38e8b20ccaaa9e91fdc37aa3b8c2", "sha256:a98005aab7613b6fe7a1af7192a5163f72a52d3348d3918e6c7a2a32e4012779",
"sha256:2845bd3b479be9db8556801e7ebc2746ce2d9edb4e7b64a1c786ecbfc1e5867b" "sha256:bf3fd6bc54fcfd7f512ddcb80a7781f0ced2b0c83dd123d619e9468ecdaaf537"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.0" "version": "==8.0.0"
}, },
"flask-compress": { "h11": {
"hashes": [ "hashes": [
"sha256:468693f4ddd11ac6a41bca4eb5f94b071b763256d54136f77957cfee635badb3" "sha256:311dc5478c2568cc07262e0381cdfc5b9c6ba19775905736c87e81ae6662b9fd",
"sha256:9eecfbafc980976dbff26a01dd3487644dd5d00f8038584451fc64a660f7c502"
], ],
"index": "pypi", "version": "==0.10.0"
"version": "==1.4.0"
}, },
"flask-talisman": { "h2": {
"hashes": [ "hashes": [
"sha256:468131464a249274ed226efc21b372518f442487e58918ccab8357eaa638fd1f", "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5",
"sha256:eaa754f4b771dfbe473843391d69643b79e3a38c865790011ac5e4179c68e3ec" "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"
], ],
"index": "pypi", "version": "==3.2.0"
"version": "==0.7.0"
}, },
"gevent": { "hpack": {
"hashes": [ "hashes": [
"sha256:0b84a8d6f088b29a74402728681c9f11864b95e49f5587a666e6fbf5c683e597", "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89",
"sha256:1ef086264e846371beb5742ebaeb148dc96adf72da2ff350ae5603421cdc2ad9", "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"
"sha256:2070c65896f89a85b39f49427d6132f7abd047129fc4da88b3670f0ba13b0cf7",
"sha256:2fbe0bc43d8c5540153f06eece6235dda14e5f99bdd9183838396313100815d7",
"sha256:32813de352918fb652a3db805fd6e08e0a1666a1a9304eef95938c9c426f9573",
"sha256:38c45d8a3b647f56f8a68769a8ac4953be84a84735c7c7a4d7ca62022bd54036",
"sha256:3b4c4d99f87c0d04b825879c5a91fbfa2b66da7c25b8689e9bdd9f4741d5f80d",
"sha256:42cae3be36b7458f411bd589c66aaba27e4e611ec3d3621e37fd732fe383f9b6",
"sha256:4572dc7907a0ac3c39b9f0898dbdf390ae3250baaae5f7395661fb844e2e23be",
"sha256:6088bedd8b6bcdb815be322304a5d1c028ffa837d84e93b349928dadac62f354",
"sha256:8a9aba59a3268f20c7b584119215bdc589cb81500d93dad4dab428eb02f72944",
"sha256:8cca7ffd58559f8d51e5605ad73afcc6f348f9747d2fa539b336e70851b69b79",
"sha256:956e82a5d0e90f8d71efe4cecccde602cfb657cd866c58bb953c9c30ca1b3d77",
"sha256:b0aea12de542f8fcd6882087bdd5b4d7dc8bb316d28181f6b012dd0b91583285",
"sha256:b46399f6c9eccc2e6de1dc1057d362be840443e5439b06cce8b01d114ba1a7ec",
"sha256:c0b38a654c8fde5b9d9bd27ea3261aeefe36bc9244b170b6d3b11d72a2163bdb",
"sha256:c516cc5d70c3faf07f271d50930d144339c69fb80f3cac9b687aa964e518535e",
"sha256:c7a62d51c6dca84f91a91b940037523c926a516f0568f47dc1386bd1682cf4e9",
"sha256:cea28f958bc4206ae092043e0775cd7a2bb2536bcbece292732c6484c1076c01",
"sha256:d56f36eb98532d2bccc51cb0964c31e9fbd9b2282074c297dc9b006b047e2966",
"sha256:de6c0cbcb890d0a79323961d3b593a0f2f54dcb9fe38ee5167f2d514e69e3c8c",
"sha256:e0990009e7c1624f9a0f3335df1ab8d45678241c852659ac645b70ed8229097c",
"sha256:e7d23d5f32c9db6ae49c4b58585618dcafd6ad0babae251c9c8297afebc4744b",
"sha256:ee39caf14d66e619709cdfe3962bc68a234518e43ea8c811c0d67a864bc7c196"
], ],
"index": "pypi", "version": "==3.0.0"
"version": "==20.4.0"
}, },
"googleapis-common-protos": { "hypercorn": {
"hashes": [ "hashes": [
"sha256:013c91704279119150e44ef770086fdbba158c1f978a6402167d47d5409e226e" "sha256:19f32e7267225c8108ad585b2c5deddf1fe75950797a0e87a682a3a00ef1af95",
"sha256:809d77f3bf9fa0794a598d8dfa0f8d889e7e1c2f927581cd33068803169dc474"
], ],
"version": "==1.51.0" "markers": "python_version >= '3.7'",
"version": "==0.10.2"
}, },
"greenlet": { "hyperframe": {
"hashes": [ "hashes": [
"sha256:000546ad01e6389e98626c1367be58efa613fa82a1be98b0c6fc24b563acc6d0", "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40",
"sha256:0d48200bc50cbf498716712129eef819b1729339e34c3ae71656964dac907c28", "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"
"sha256:23d12eacffa9d0f290c0fe0c4e81ba6d5f3a5b7ac3c30a5eaf0126bf4deda5c8",
"sha256:37c9ba82bd82eb6a23c2e5acc03055c0e45697253b2393c9a50cef76a3985304",
"sha256:51155342eb4d6058a0ffcd98a798fe6ba21195517da97e15fca3db12ab201e6e",
"sha256:51503524dd6f152ab4ad1fbd168fc6c30b5795e8c70be4410a64940b3abb55c0",
"sha256:7457d685158522df483196b16ec648b28f8e847861adb01a55d41134e7734122",
"sha256:8041e2de00e745c0e05a502d6e6db310db7faa7c979b3a5877123548a4c0b214",
"sha256:81fcd96a275209ef117e9ec91f75c731fa18dcfd9ffaa1c0adbdaa3616a86043",
"sha256:853da4f9563d982e4121fed8c92eea1a4594a2299037b3034c3c898cb8e933d6",
"sha256:8b4572c334593d449113f9dc8d19b93b7b271bdbe90ba7509eb178923327b625",
"sha256:9416443e219356e3c31f1f918a91badf2e37acf297e2fa13d24d1cc2380f8fbc",
"sha256:9854f612e1b59ec66804931df5add3b2d5ef0067748ea29dc60f0efdcda9a638",
"sha256:99a26afdb82ea83a265137a398f570402aa1f2b5dfb4ac3300c026931817b163",
"sha256:a19bf883b3384957e4a4a13e6bd1ae3d85ae87f4beb5957e35b0be287f12f4e4",
"sha256:a9f145660588187ff835c55a7d2ddf6abfc570c2651c276d3d4be8a2766db490",
"sha256:ac57fcdcfb0b73bb3203b58a14501abb7e5ff9ea5e2edfa06bb03035f0cff248",
"sha256:bcb530089ff24f6458a81ac3fa699e8c00194208a724b644ecc68422e1111939",
"sha256:beeabe25c3b704f7d56b573f7d2ff88fc99f0138e43480cecdfcaa3b87fe4f87",
"sha256:d634a7ea1fc3380ff96f9e44d8d22f38418c1c381d5fac680b272d7d90883720",
"sha256:d97b0661e1aead761f0ded3b769044bb00ed5d33e1ec865e891a8b128bf7c656",
"sha256:e538b8dae561080b542b0f5af64d47ef859f22517f7eca617bb314e0e03fd7ef"
], ],
"markers": "platform_python_implementation == 'CPython'", "version": "==5.2.0"
"version": "==0.4.15"
},
"grpcio": {
"hashes": [
"sha256:085bbf7fd0070b8d65e84aa32979f17cfe624d27b5ce23955ef770c19d2d9623",
"sha256:0ae207a47ec0ad66eb1f53a27d566674d13a236c62ced409891335318ea9b8c5",
"sha256:0c130204ff5de0b9f041bf3126db0d29369d69883592e4b0d3c19868ba0ced7e",
"sha256:0ef6b380a588c2c6b29c6cfa0ba7f5d367beb33d5504bcc68658fa241ad498d2",
"sha256:16e1edb367763ea08d0994d4635ec05f4f8db9db59c39304b061097e3b93df43",
"sha256:16f5523dacae5aaeda4cf900da7e980747f663298c38c18eb4e5317704aa007a",
"sha256:181b5078cf568f37915b8a118afcef5fc9f3128c59c38998ed93e7dd793e3928",
"sha256:245564713cb4ac7bccb0f11be63781beb62299a44d8ab69031c859dbd9461728",
"sha256:271abbe28eb99fa5c70b3f272c0c66b67dab7bb11e1d29d8e616b4e0e099d29a",
"sha256:2e1b01cba26988c811c7fb91a0bca19c9afb776cc3d228993f08d324bdd0510a",
"sha256:3366bd6412c1e73acb1ee27d7f0c7d7dbee118ad8d98c957c8173691b2effeec",
"sha256:3893b39a0a17d857dc3a42fdb02a26aa53a59bfce49987187bcc0261647f1f55",
"sha256:3c7864d5ae63b787001b01b376f6315aef1a015aa9c809535235ed0ead907919",
"sha256:42c6716adf3ec1f608b2b56e885f26dd86e80d2fc1617f51fc92d1b0b649e28e",
"sha256:4bef0756b9e0df78e8d67a5b1e0e89b7daf41525d575f74e1f14a993c55b680d",
"sha256:4fe081862e58b8fbef0e479aefc9a64f8f17f53074df1085d8c1fe825a6e5df4",
"sha256:505a8d1b4ac571a51f10c4c995d5d4714f03c886604dc3c097ef5fd57bcfcf0b",
"sha256:5c2e81b6ab9768c43f2ca1c9a4c925823aad79ae95efb351007df4b92ebce592",
"sha256:70ff2df0c1795c5cf585a72d95bb458838b40bad5653c314b9067ba819e918f9",
"sha256:97b5612fc5d4bbf0490a2d80bed5eab5b59112ef1640440c1a9ac824bafa6968",
"sha256:a35f8f4a0334ed8b05db90383aecef8e49923ab430689a4360a74052f3a89cf4",
"sha256:aafe85a8210dfa1da3c46831b7f00c3735240b7b028eeba339eaea6ffdb593fb",
"sha256:c2e53eb253840f05278a8410628419ba7060815f86d48c9d83b6047de21c9956",
"sha256:c3645887db3309fc87c3db740b977d403fb265ebab292f1f6a926c4661231fd5",
"sha256:c6565cc92853af13237b2233f331efdad07339d27fe1f5f74256bfde7dc2f587",
"sha256:cbc322c5d5615e67c2a15be631f64e6c2bab8c12505bc7c150948abdaa0bdbac",
"sha256:df749ee982ec35ab76d37a1e637b10a92b4573e2b4e1f86a5fa8a1273c40a850",
"sha256:e9439d7b801c86df13c6cbb4c5a7e181c058f3c119d5e119a94a5f3090a8f060",
"sha256:f493ac4754717f25ace3614a51dd408a32b8bff3c9c0c85e9356e7e0a120a8c8",
"sha256:f80d10bdf1a306f7063046321fd4efc7732a606acdd4e6259b8a37349079b704",
"sha256:f83b0c91796eb42865451a20e82246011078ba067ea0744f7301e12a94ae2e1b"
],
"version": "==1.28.1"
},
"grpcio-tools": {
"hashes": [
"sha256:095911bd21dd2dc029b8862b44e65b41341135fd16da60ad9d0df4b8e9a0618e",
"sha256:17a6b63ee1650abbbc9306f3144d1d3bd3d088a270ca6569afcb68a8cc4a4a38",
"sha256:27fcab84ea948d965f82c7994903e8e36a20ce4ad7ef55a22c8d7c639e6c8808",
"sha256:2ffa5f6fbd26ea6c7626bf89d6ef4d64d111000a8bd4e9df69b070093de0f85d",
"sha256:491dcd98d988c0a4ed9bbb1ec6310588fc0e16b334b64119b973363a1cf96cc1",
"sha256:493b5a275ff2692942951926a259e396ff13acec72e6d93498638006b1a1bd71",
"sha256:4c980208c1620e52e86c4037058097673ee8e3bbbec34582f2538e614d0c41f4",
"sha256:55f3a86c3a808b8a5c2dc8112c45329dd912e43a6fb474da94a7e04d8bb36e1a",
"sha256:69a60e1c020547668f313b7a81d653f8c820066ab0f6ed979af760905b61d9e1",
"sha256:6f7806fe53e5979fa10ec49cbd5236cf0e57c8817ce54350bccc8ebceaeb54fb",
"sha256:8f752fa03eb2d851aac5838aa46a5b831660e9800634fad8ccd46539e9cb4663",
"sha256:9210b3575fee355a4056cff8ff7212b823371e9fa9ad1467e3c0c5cb006d0d7d",
"sha256:9b900cf90b293acd48bf258d5e416eae9540b5aa0c75cc2fe68a7e2c5f4834ae",
"sha256:addc40a47f496cafa108ba1a31838a9832223e0dcafaf424d38e6b8c79fe26ac",
"sha256:adf089aaf6e21358b12e39d9fa7c28611340d8399a918c0b72ff122ce9b7e0af",
"sha256:b22172e3ad5e849815f3e35a002fb67c52e83cd3e4299cf45760de4674c7cd04",
"sha256:b5cf457dcd6499f8c2f2bdcc486d681ca3ee673064484a31c507df6139bf00fe",
"sha256:b5ee6f089bb040d175e658911760584c7a6cb70fbdcc78bdbe6349475933483b",
"sha256:bb9b1a05917011e98e5331d129a40a41f49c5652e1b86d5fea9b3f6c98454928",
"sha256:c527db050fa619a2bd293a73db0e0af42f0bd5a4f41c043fc5da543d81f5fed6",
"sha256:c9efe6420d2ffaf9aa32d292817ddfc939b9adf28a51cef30984bab02ed35a58",
"sha256:cb7b54a96f4d9aae5d9c6b644a69ce48c12cfa3558c86ce0a62d9c8a08a83f2e",
"sha256:d661e5c4f82f9a223b9d1a6c9ec68faea060324e0d25684a6f7fb0b9622ab561",
"sha256:d6ee6f338e0099976161e78ecf91d2ab9c04be75a944ef29facc93a4aad28e1d",
"sha256:d89dec894495c51ee0a8dd0c896e4320e95bab179c3faa6a439ef5719486c35e",
"sha256:dc7ea3739aa6f5abe6e9a27f403b30c7996978176e5884c41ff3d8a153e7c35a",
"sha256:de10f8b28ef3e8a7f336e02b7c2912713bd41e0931c4fad3a6993d25c8511159",
"sha256:e05f7bfb63b844b7f9d8b50019b02168fd8982278dc0443986f3504b3cb87d2a",
"sha256:e942dd349d5be0d5ebbe96ed9cbcfb5fdb0bc7b57357754c42db8083a0ec80aa",
"sha256:edc01f022a878722ae69bff3faa8c6ec7e967d57b7e8a5f4b2f6100ad903a978",
"sha256:f61d219d06057e015d662c0b40438f119f58e514a5bebf425058a34d05ff21db"
],
"version": "==1.28.1"
},
"gunicorn": {
"hashes": [
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
],
"index": "pypi",
"version": "==20.0.4"
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
], ],
"version": "==2.9" "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10"
}, },
"itsdangerous": { "itsdangerous": {
"hashes": [ "hashes": [
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.0" "version": "==1.1.0"
}, },
"jinja2": { "jinja2": {
@@ -254,23 +178,15 @@
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" "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" "version": "==2.11.2"
}, },
"lnd-grpc": {
"hashes": [
"sha256:43114aead2dc2d008751231784fc94027d60569a5e4dc86ed68032a2203fe781",
"sha256:c28208d912b28f5b0b11fa87559c6e564731549ecbc52eafe7d5dd2b2f469cf8"
],
"index": "pypi",
"version": "==0.4.0"
},
"lnurl": { "lnurl": {
"hashes": [ "hashes": [
"sha256:633ddedddcff4bd8b7790eba1649ec10c91fb28e690e3f6e984cb8591be3b626", "sha256:aaff8552cd4c02f8ae30c82dd3ebe6c6c3a8e06cbc59991281534ba0bbe12890"
"sha256:749e0d73a8e5d41b03c734df4f22c0e022f1da4f64e600a898b56e1bb75f5dda"
], ],
"index": "pypi", "index": "pypi",
"version": "==0.3.3" "version": "==0.3.5"
}, },
"markupsafe": { "markupsafe": {
"hashes": [ "hashes": [
@@ -308,60 +224,46 @@
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"protobuf": { "marshmallow": {
"hashes": [ "hashes": [
"sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab", "sha256:2272273505f1644580fbc66c6b220cc78f893eb31f1ecde2af98ad28011e9811",
"sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f", "sha256:47911dd7c641a27160f0df5fd0fe94667160ffe97f70a42c3cc18388d86098cc"
"sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a",
"sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0",
"sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4",
"sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2",
"sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee",
"sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07",
"sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151",
"sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a",
"sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f",
"sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7",
"sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956",
"sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306",
"sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961",
"sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481",
"sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a",
"sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80"
], ],
"version": "==3.11.3" "markers": "python_version >= '3.5'",
"version": "==3.8.0"
},
"priority": {
"hashes": [
"sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe",
"sha256:be4fcb94b5e37cdeb40af5533afe6dd603bd665fe9c8b3052610fc1001d5d1eb"
],
"version": "==1.3.0"
}, },
"pydantic": { "pydantic": {
"hashes": [ "hashes": [
"sha256:0b7aadfa1de28057656064e04d9f018d1b186fe2a8e953a2fb41545873b7cf95", "sha256:1783c1d927f9e1366e0e0609ae324039b2479a1a282a98ed6a6836c9ed02002c",
"sha256:0f61e67291b99a927816558a218a4e794db72a33621c836e63d12613a2202cd4", "sha256:2dc946b07cf24bee4737ced0ae77e2ea6bc97489ba5a035b603bd1b40ad81f7e",
"sha256:20946280c750753b3e3177c748825ef189d7ab86c514f6a0b118621110d5f0d3", "sha256:2de562a456c4ecdc80cf1a8c3e70c666625f7d02d89a6174ecf63754c734592e",
"sha256:22139ee446992c222977ac0a9269c4da2e9ecc1834f84804ebde008a4649b929", "sha256:36dbf6f1be212ab37b5fda07667461a9219c956181aa5570a00edfb0acdfe4a1",
"sha256:3c0f39e884d7a3572d5cc8322b0fe9bf66114283e22e05a5c4b8961c19588945", "sha256:3fa799f3cfff3e5f536cbd389368fc96a44bb30308f258c94ee76b73bd60531d",
"sha256:446ce773a552a2cb90065d4aa645e16fa7494369b5f0d199e4d41a992a98204d", "sha256:40d765fa2d31d5be8e29c1794657ad46f5ee583a565c83cea56630d3ae5878b9",
"sha256:475e6606873e40717cc3b0eebc7d1101cbfc774e01dadeeea24c121eb5826b86", "sha256:418b84654b60e44c0cdd5384294b0e4bc1ebf42d6e873819424f3b78b8690614",
"sha256:66124752662de0479a9d0c17bdebdc8a889bccad8846626fb66d8669e8eafb63", "sha256:4900b8820b687c9a3ed753684337979574df20e6ebe4227381d04b3c3c628f99",
"sha256:896637b7d8e4cdc0bcee1704fcadacdd167c35ac29f02a4395fce7a033925f26", "sha256:530d7222a2786a97bc59ee0e0ebbe23728f82974b1f1ad9a11cd966143410633",
"sha256:9af44d06db33896a2176603c9cb876df3a60297a292a24d3018956a910cc1402", "sha256:54122a8ed6b75fe1dd80797f8251ad2063ea348a03b77218d73ea9fe19bd4e73",
"sha256:9e46fac8a4674db0777fd0133aa56817e1481beee50971bab39dded7639f9b2b", "sha256:6c3f162ba175678218629f446a947e3356415b6b09122dcb364e58c442c645a7",
"sha256:ae206e103e976c40ec294cd6c8fcbfbdaced3ab9b736bc53d03fa11b5aaa1628", "sha256:b49c86aecde15cde33835d5d6360e55f5e0067bb7143a8303bf03b872935c75b",
"sha256:b11d0bd7ecf41098894e8777ee623c29554dbaa37e862c51bcc5a2b950d1bf77", "sha256:b5b3489cb303d0f41ad4a7390cf606a5f2c7a94dcba20c051cd1c653694cb14d",
"sha256:d73070028f7b046a5b2e611a9799c238d7bd245f8fe30f4ad7ff29ddb63aac40", "sha256:cf3933c98cb5e808b62fae509f74f209730b180b1e3c3954ee3f7949e083a7df",
"sha256:ddedcdf9d5c24939578449a8e099ceeec3b3d76243fc143aff63ebf6d5aade10", "sha256:eb75dc1809875d5738df14b6566ccf9fd9c0bcde4f36b72870f318f16b9f5c20",
"sha256:e08e21f4d5395ac17cde19de26be63fb16fb870f0cfde1481ddc22d5e2353548", "sha256:f769141ab0abfadf3305d4fcf36660e5cf568a666dd3efab7c3d4782f70946b1",
"sha256:e6239199b363bc53262bcb57f1441206d4b2d46b392eccba2213d8358d6e284a" "sha256:f8af9b840a9074e08c0e6dc93101de84ba95df89b267bf7151d74c553d66833b"
], ],
"version": "==1.5" "markers": "python_version >= '3.6'",
}, "version": "==1.6.1"
"pylightning": {
"hashes": [
"sha256:5b7df2c6f0faf17450ea3024f29f901a114d4c68da116a1b130958df510a0557",
"sha256:6a07b62b27e01baaf51165e2dfb8b7d1d47f61aef8304a9da3ca5081608a32c0"
],
"index": "pypi",
"version": "==0.0.7.3"
}, },
"pyscss": { "pyscss": {
"hashes": [ "hashes": [
@@ -370,13 +272,52 @@
"index": "pypi", "index": "pypi",
"version": "==1.3.7" "version": "==1.3.7"
}, },
"requests": { "python-dotenv": {
"hashes": [ "hashes": [
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", "sha256:8c10c99a1b25d9a68058a1ad6f90381a62ba68230ca93966882a4dbc3bc9c33d",
"sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" "sha256:c10863aee750ad720f4f43436565e4c1698798d763b63234fb5021b6c616e423"
],
"version": "==0.14.0"
},
"quart": {
"hashes": [
"sha256:9c634e4c1e4b21b824003c676de1583581258c72b0ac4d2ba747db846e97ff56",
"sha256:d885d782edd9d5dcfd2c4a56e020db3b82493d4c3950f91c221b7d88d239ac93"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.23.0" "version": "==0.13.1"
},
"quart-compress": {
"hashes": [
"sha256:41cd0cc8d26905a45025ddda7022461a71b9d1d950b21b006dc106a1c41c75ef",
"sha256:63af5e6370aa7850fb219d22e1db89965aeb13b8f27bc83e7f9a44118faa3c54"
],
"index": "pypi",
"version": "==0.2.1"
},
"quart-cors": {
"hashes": [
"sha256:020a17d504264db86cada3c1335ef174af28b33f57cee321ddc46d69c33d5c8e",
"sha256:c08bdb326219b6c186d19ed6a97a7fd02de8fe36c7856af889494c69b525c53c"
],
"index": "pypi",
"version": "==0.3.0"
},
"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": { "shortuuid": {
"hashes": [ "hashes": [
@@ -388,171 +329,158 @@
}, },
"six": { "six": {
"hashes": [ "hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
], ],
"version": "==1.14.0" "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": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
"sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
"sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
], ],
"markers": "python_version < '3.8'", "index": "pypi",
"version": "==3.7.4.2" "version": "==3.7.4.3"
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
"sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
], ],
"version": "==1.25.9" "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
}, "version": "==1.25.10"
"webassets": {
"hashes": [
"sha256:167132337677c8cedc9705090f6d48da3fb262c8e0b2773b29f3352f050181cd",
"sha256:a31a55147752ba1b3dc07dee0ad8c8efff274464e08bbdb88c1fd59ffd552724"
],
"version": "==2.0"
}, },
"werkzeug": { "werkzeug": {
"hashes": [ "hashes": [
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
"sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" "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" "version": "==1.0.1"
},
"wsproto": {
"hashes": [
"sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d",
"sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==0.15.0"
} }
}, },
"develop": { "develop": {
"appdirs": { "appdirs": {
"hashes": [ "hashes": [
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
], ],
"version": "==1.4.3" "version": "==1.4.4"
}, },
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"
], ],
"version": "==19.3.0" "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.2.0"
}, },
"black": { "black": {
"hashes": [ "hashes": [
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b", "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
], ],
"index": "pypi", "index": "pypi",
"version": "==19.10b0" "version": "==20.8b1"
}, },
"click": { "click": {
"hashes": [ "hashes": [
"sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc", "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
"sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a" "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
], ],
"version": "==7.1.1" "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": { "coverage": {
"hashes": [ "hashes": [
"sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", "sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516",
"sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", "sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259",
"sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", "sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9",
"sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", "sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097",
"sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", "sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0",
"sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", "sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f",
"sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", "sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7",
"sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", "sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c",
"sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", "sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5",
"sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", "sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7",
"sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", "sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729",
"sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", "sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978",
"sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", "sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9",
"sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", "sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f",
"sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", "sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9",
"sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", "sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822",
"sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", "sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418",
"sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", "sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82",
"sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", "sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f",
"sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", "sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d",
"sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", "sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221",
"sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", "sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4",
"sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", "sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21",
"sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", "sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709",
"sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", "sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54",
"sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", "sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d",
"sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", "sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270",
"sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", "sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24",
"sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", "sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751",
"sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", "sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a",
"sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" "sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237",
"sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7",
"sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636",
"sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8"
], ],
"version": "==5.1" "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"
}, },
"entrypoints": { "iniconfig": {
"hashes": [ "hashes": [
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437",
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"
], ],
"version": "==0.3" "version": "==1.0.1"
},
"flake8": {
"hashes": [
"sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
"sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
],
"index": "pypi",
"version": "==3.7.9"
},
"flake8-mypy": {
"hashes": [
"sha256:47120db63aff631ee1f84bac6fe8e64731dc66da3efc1c51f85e15ade4a3ba18",
"sha256:cff009f4250e8391bf48990093cff85802778c345c8449d6498b62efefeebcbc"
],
"index": "pypi",
"version": "==17.8.0"
},
"importlib-metadata": {
"hashes": [
"sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f",
"sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"
],
"markers": "python_version < '3.8'",
"version": "==1.6.0"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
}, },
"more-itertools": { "more-itertools": {
"hashes": [ "hashes": [
"sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c", "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20",
"sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507" "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c"
], ],
"version": "==8.2.0" "markers": "python_version >= '3.5'",
"version": "==8.5.0"
}, },
"mypy": { "mypy": {
"hashes": [ "hashes": [
"sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2", "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
"sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1", "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7",
"sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164", "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2",
"sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761", "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474",
"sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce", "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0",
"sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27", "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217",
"sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754", "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749",
"sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae", "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6",
"sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9", "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf",
"sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600", "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36",
"sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65", "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b",
"sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8", "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72",
"sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913", "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1",
"sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3" "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"
], ],
"version": "==0.770" "index": "pypi",
"version": "==0.761"
}, },
"mypy-extensions": { "mypy-extensions": {
"hashes": [ "hashes": [
@@ -563,10 +491,11 @@
}, },
"packaging": { "packaging": {
"hashes": [ "hashes": [
"sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3", "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8",
"sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752" "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"
], ],
"version": "==20.3" "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==20.4"
}, },
"pathspec": { "pathspec": {
"hashes": [ "hashes": [
@@ -580,105 +509,89 @@
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.13.1" "version": "==0.13.1"
}, },
"py": { "py": {
"hashes": [ "hashes": [
"sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2",
"sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"
], ],
"version": "==1.8.1" "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
}, "version": "==1.9.0"
"pycodestyle": {
"hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
],
"version": "==2.5.0"
},
"pyflakes": {
"hashes": [
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
],
"version": "==2.1.1"
}, },
"pyparsing": { "pyparsing": {
"hashes": [ "hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
], ],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.4.7" "version": "==2.4.7"
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [
"sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172", "sha256:0e37f61339c4578776e090c3b8f6b16ce4db333889d65d0efb305243ec544b40",
"sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970" "sha256:c8f57c2a30983f469bf03e68cdfa74dc474ce56b8f280ddcb080dfd91df01043"
], ],
"index": "pypi", "index": "pypi",
"version": "==5.4.1" "version": "==6.0.2"
},
"pytest-asyncio": {
"hashes": [
"sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d",
"sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"
],
"index": "pypi",
"version": "==0.14.0"
}, },
"pytest-cov": { "pytest-cov": {
"hashes": [ "hashes": [
"sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b", "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191",
"sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626" "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.8.1" "version": "==2.10.1"
},
"pytest-sugar": {
"hashes": [
"sha256:26cf8289fe10880cbbc130bd77398c4e6a8b936d8393b116a5c16121d95ab283",
"sha256:fcd87a74b2bce5386d244b49ad60549bfbc4602527797fac167da147983f58ab"
],
"index": "pypi",
"version": "==0.9.2"
}, },
"regex": { "regex": {
"hashes": [ "hashes": [
"sha256:08119f707f0ebf2da60d2f24c2f39ca616277bb67ef6c92b72cbf90cbe3a556b", "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204",
"sha256:0ce9537396d8f556bcfc317c65b6a0705320701e5ce511f05fc04421ba05b8a8", "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162",
"sha256:1cbe0fa0b7f673400eb29e9ef41d4f53638f65f9a2143854de6b1ce2899185c3", "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f",
"sha256:2294f8b70e058a2553cd009df003a20802ef75b3c629506be20687df0908177e", "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb",
"sha256:23069d9c07e115537f37270d1d5faea3e0bdded8279081c4d4d607a2ad393683", "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6",
"sha256:24f4f4062eb16c5bbfff6a22312e8eab92c2c99c51a02e39b4eae54ce8255cd1", "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7",
"sha256:295badf61a51add2d428a46b8580309c520d8b26e769868b922750cf3ce67142", "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88",
"sha256:2a3bf8b48f8e37c3a40bb3f854bf0121c194e69a650b209628d951190b862de3", "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99",
"sha256:4385f12aa289d79419fede43f979e372f527892ac44a541b5446617e4406c468", "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644",
"sha256:5635cd1ed0a12b4c42cce18a8d2fb53ff13ff537f09de5fd791e97de27b6400e", "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a",
"sha256:5bfed051dbff32fd8945eccca70f5e22b55e4148d2a8a45141a3b053d6455ae3", "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840",
"sha256:7e1037073b1b7053ee74c3c6c0ada80f3501ec29d5f46e42669378eae6d4405a", "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067",
"sha256:90742c6ff121a9c5b261b9b215cb476eea97df98ea82037ec8ac95d1be7a034f", "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd",
"sha256:a58dd45cb865be0ce1d5ecc4cfc85cd8c6867bea66733623e54bd95131f473b6", "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4",
"sha256:c087bff162158536387c53647411db09b6ee3f9603c334c90943e97b1052a156", "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e",
"sha256:c162a21e0da33eb3d31a3ac17a51db5e634fc347f650d271f0305d96601dc15b", "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89",
"sha256:c9423a150d3a4fc0f3f2aae897a59919acd293f4cb397429b120a5fcd96ea3db", "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e",
"sha256:ccccdd84912875e34c5ad2d06e1989d890d43af6c2242c6fcfa51556997af6cd", "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc",
"sha256:e91ba11da11cf770f389e47c3f5c30473e6d85e06d7fd9dcba0017d2867aab4a", "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf",
"sha256:ea4adf02d23b437684cd388d557bf76e3afa72f7fed5bbc013482cc00c816948", "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341",
"sha256:fb95debbd1a824b2c4376932f2216cc186912e389bdb0e27147778cf6acb3f89" "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"
], ],
"version": "==2020.4.4" "version": "==2020.7.14"
}, },
"six": { "six": {
"hashes": [ "hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"
], ],
"version": "==1.14.0" "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
}, "version": "==1.15.0"
"termcolor": {
"hashes": [
"sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"
],
"version": "==1.1.0"
}, },
"toml": { "toml": {
"hashes": [ "hashes": [
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
], ],
"version": "==0.10.0" "version": "==0.10.1"
}, },
"typed-ast": { "typed-ast": {
"hashes": [ "hashes": [
@@ -708,26 +621,12 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
"sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
"sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
], ],
"markers": "python_version < '3.8'", "index": "pypi",
"version": "==3.7.4.2" "version": "==3.7.4.3"
},
"wcwidth": {
"hashes": [
"sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1",
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
],
"version": "==0.1.9"
},
"zipp": {
"hashes": [
"sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
"sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
],
"version": "==3.1.0"
} }
} }
} }
+1 -1
View File
@@ -1 +1 @@
web: gunicorn -b :5000 lnbits:app -k gevent web: hypercorn --bind 0.0.0.0:5000 lnbits:app
+40 -35
View File
@@ -1,9 +1,21 @@
LNbits
======
[![github-tests-badge]][github-tests]
[![github-mypy-badge]][github-mypy]
[![codecov-badge]][codecov]
[![license-badge]](LICENSE)
[![docs-badge]][docs]
![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png) ![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png)
# LNbits v0.1 BETA, free and open-source lightning-network wallet/accounts system # LNbits v0.1 BETA, free and open-source lightning-network wallet/accounts system
https://lnbits.com, or run your own LNbits server!
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: 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 * Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
* Extendable platform for exploring lightning-network functionality via LNbits extension framework * Extendable platform for exploring lightning-network functionality via LNbits extension framework
* Part of a development stack via LNbits API * Part of a development stack via LNbits API
@@ -12,9 +24,19 @@ LNbits is a very simple Python server that sits on top of any funding source, an
The wallet can run on top of any lightning-network funding source, currently there is support for LND, CLightning, Lntxbot, LNpay, OpenNode, with more being added regularily. The wallet can run on top of any lightning-network funding source, currently there is support for LND, CLightning, Lntxbot, LNpay, OpenNode, with more being added regularily.
## LNbits as an account system See [lnbits.org](https://lnbits.org) for more detailed documentation.
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..
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 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..
![Lightning network wallet](https://i.imgur.com/w8jdGpF.png) ![Lightning network wallet](https://i.imgur.com/w8jdGpF.png)
@@ -25,51 +47,34 @@ Each wallet also comes with its own API keys, to help partition the exposure of
![lnurl ATM](https://i.imgur.com/WfCg8wY.png) ![lnurl ATM](https://i.imgur.com/WfCg8wY.png)
## LNbits as an LNURL-withdraw fallback ## 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).
![lnurl fallback](https://i.imgur.com/CPBKHIv.png) ![lnurl fallback](https://i.imgur.com/CPBKHIv.png)
https://github.com/btcontract/lnurl-rfc/blob/master/spec.md
Adding **/lnurlwallet?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet. Using **lnbits.com/?lightning="LNURL-withdraw"** will trigger a withdraw that builds an LNbits wallet.
Example use would be an ATM, which utilises LNURL, if the user scans the QR with a regular QR code scanner app, they will stilll be able to access the funds. 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.
![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg) ![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg)
## LNbits as an insta-wallet ## 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). 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". "Go to this website", has a lot less friction than "Download this app".
![lnurl ATM](https://i.imgur.com/xFWDnwy.png) ![lnurl ATM](https://i.imgur.com/xFWDnwy.png)
# Running LNbits locally ## Tip me
Download this repo
LNbits uses [Flask](http://flask.pocoo.org/). If you like this project and might even use or extend it, why not [send some tip love](https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
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 [docs]: https://lnbits.org/
$ pipenv install --dev [docs-badge]: https://img.shields.io/badge/docs-lnbits.org-673ab7.svg
[github-mypy]: https://github.com/lnbits/lnbits/actions?query=workflow%3Amypy
You will need to set the variables in .env.example, and rename the file to .env [github-mypy-badge]: https://github.com/lnbits/lnbits/workflows/mypy/badge.svg
[github-tests]: https://github.com/lnbits/lnbits/actions?query=workflow%3Atests
![lnurl ATM](https://i.imgur.com/ri2zOe8.png) [github-tests-badge]: https://github.com/lnbits/lnbits/workflows/tests/badge.svg
[codecov]: https://codecov.io/gh/lnbits/lnbits
Running the server [codecov-badge]: https://codecov.io/gh/lnbits/lnbits/branch/master/graph/badge.svg
------------------ [license-badge]: https://img.shields.io/badge/license-MIT-blue.svg
$ flask migrate
$ flask run
There is an environment variable called `FLASK_ENV` that has to be set to `development`
if you want to run Flask in debug mode with autoreload
[pipenv]: https://docs.pipenv.org/#install-pipenv-today
# Tip me
If you like this project and might even use or extend it, why not send some tip love!
https://paywall.link/to/f4e4e
+1 -3
View File
@@ -1,7 +1,5 @@
{ {
"scripts": { "scripts": {
"dokku": { "dokku": {}
"predeploy": "flask migrate"
}
} }
} }
+5
View File
@@ -0,0 +1,5 @@
_site
.sass-cache
.jekyll-cache
.jekyll-metadata
vendor
+1
View File
@@ -0,0 +1 @@
lnbits.org
+11
View File
@@ -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"
+12
View File
@@ -0,0 +1,12 @@
---
layout: default
parent: For developers
title: API reference
nav_order: 3
---
API reference
=============
Coming soon...
+12
View File
@@ -0,0 +1,12 @@
---
layout: default
title: For developers
nav_order: 4
has_children: true
---
For developers
==============
Thanks for contributing :)
+43
View File
@@ -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
```
+60
View File
@@ -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`.
![Files](https://i.imgur.com/ri2zOe8.png)
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
+33
View File
@@ -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
```
+84
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
---
layout: default
title: Users 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...
-94
View File
@@ -1,94 +0,0 @@
import importlib
from flask import Flask
from flask_assets import Environment, Bundle # type: ignore
from flask_compress import Compress # type: ignore
from flask_talisman import Talisman # type: ignore
from os import getenv
from werkzeug.middleware.proxy_fix import ProxyFix
from .core import core_app, migrations as core_migrations
from .helpers import ExtensionManager
from .settings import FORCE_HTTPS
disabled_extensions = getenv("LNBITS_DISABLED_EXTENSIONS", "").split(",")
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) # type: ignore
valid_extensions = [ext for ext in ExtensionManager(disabled=disabled_extensions).extensions if ext.is_valid]
# optimization & security
# -----------------------
Compress(app)
Talisman(
app,
force_https=FORCE_HTTPS,
content_security_policy={
"default-src": [
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"blob:",
"api.opennode.co",
"fonts.googleapis.com",
"fonts.gstatic.com",
"github.com",
"avatars2.githubusercontent.com",
]
},
)
# blueprints / extensions
# -----------------------
app.register_blueprint(core_app)
for ext in valid_extensions:
try:
ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}")
app.register_blueprint(getattr(ext_module, f"{ext.code}_ext"), url_prefix=f"/{ext.code}")
except Exception:
raise ImportError(f"Please make sure that the extension `{ext.code}` follows conventions.")
# filters
# -------
app.jinja_env.globals["DEBUG"] = app.config["DEBUG"]
app.jinja_env.globals["EXTENSIONS"] = valid_extensions
app.jinja_env.globals["SITE_TITLE"] = getenv("LNBITS_SITE_TITLE", "LNbits")
# assets
# ------
assets = Environment(app)
assets.url = app.static_url_path
assets.register("base_css", Bundle("scss/base.scss", filters="pyscss", output="css/base.css"))
# commands
# --------
@app.cli.command("migrate")
def migrate_databases():
"""Creates the necessary databases if they don't exist already; or migrates them."""
core_migrations.migrate()
for ext in valid_extensions:
try:
ext_migrations = importlib.import_module(f"lnbits.extensions.{ext.code}.migrations")
ext_migrations.migrate()
except Exception:
raise ImportError(f"Please make sure that the extension `{ext.code}` has a migrations file.")
# init
# ----
if __name__ == "__main__":
app.run()
+9
View File
@@ -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"])
-7
View File
@@ -1,7 +0,0 @@
{
"scripts": {
"dokku": {
"predeploy": "flask migrate"
}
}
}
+87
View File
@@ -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)
+95 -32
View File
@@ -1,66 +1,121 @@
# type: ignore import bitstring # type: ignore
import bitstring
import re import re
from binascii import hexlify import hashlib
from bech32 import bech32_decode, CHARSET 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): class Invoice(object):
def __init__(self): payment_hash: str
self.payment_hash: str = None amount_msat: int = 0
self.amount_msat: int = 0 description: Optional[str] = None
self.description: 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: def decode(pr: str) -> Invoice:
""" Super naïve bolt11 decoder, """bolt11 decoder,
only gets payment_hash, description/description_hash and amount in msatoshi.
based on https://github.com/rustyrussell/lightning-payencode/blob/master/lnaddr.py 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"): if not hrp.startswith("ln"):
raise ValueError("Does not start with 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. # final signature 65 bytes, split it off.
if len(data) < 65 * 8: if len(bitarray) < 65 * 8:
raise ValueError("Too short to contain signature") 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() invoice = Invoice()
# decode the amount from the hrp
m = re.search("[^\d]+", hrp[2:]) m = re.search("[^\d]+", hrp[2:])
if m: if m:
amountstr = hrp[2 + m.end() :] amountstr = hrp[2 + m.end() :]
if amountstr != "": if amountstr != "":
invoice.amount_msat = unshorten_amount(amountstr) invoice.amount_msat = _unshorten_amount(amountstr)
# pull out date # pull out date
data.read(35).uint invoice.date = data.read(35).uint
while data.pos != data.len: while data.pos != data.len:
tag, tagdata, data = pull_tagged(data) tag, tagdata, data = _pull_tagged(data)
data_length = len(tagdata) / 5 data_length = len(tagdata) / 5
if tag == "d": 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: 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: 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 return invoice
def unshorten_amount(amount: str) -> int: def _unshorten_amount(amount: str) -> int:
""" Given a shortened amount, return millisatoshis """Given a shortened amount, return millisatoshis"""
"""
# BOLT #11: # BOLT #11:
# The following `multiplier` letters are defined: # The following `multiplier` letters are defined:
# #
@@ -83,18 +138,18 @@ def unshorten_amount(amount: str) -> int:
raise ValueError("Invalid amount '{}'".format(amount)) raise ValueError("Invalid amount '{}'".format(amount))
if unit in units: 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: else:
return int(amount) * 100_000_000_000 return int(amount) * 100_000_000_000
def pull_tagged(stream): def _pull_tagged(stream):
tag = stream.read(5).uint tag = stream.read(5).uint
length = stream.read(5).uint * 32 + stream.read(5).uint length = stream.read(5).uint * 32 + stream.read(5).uint
return (CHARSET[tag], stream.read(length * 5), stream) return (CHARSET[tag], stream.read(length * 5), stream)
def trim_to_bytes(barr): def _trim_to_bytes(barr):
# Adds a byte if necessary. # Adds a byte if necessary.
b = barr.tobytes() b = barr.tobytes()
if barr.len % 8 != 0: if barr.len % 8 != 0:
@@ -102,7 +157,15 @@ def trim_to_bytes(barr):
return b 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() ret = bitstring.BitArray()
for a in arr: for a in arr:
ret += bitstring.pack("uint:5", a) ret += bitstring.pack("uint:5", a)
+80
View File
@@ -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.")
+4 -2
View File
@@ -1,7 +1,9 @@
from flask import Blueprint from quart import Blueprint
core_app: Blueprint = Blueprint("core", __name__, template_folder="templates", static_folder="static") core_app: Blueprint = Blueprint(
"core", __name__, template_folder="templates", static_folder="static", static_url_path="/core/static"
)
from .views.api import * # noqa from .views.api import * # noqa
+146 -58
View File
@@ -1,7 +1,10 @@
from typing import List, Optional import json
import datetime
from uuid import uuid4 from uuid import uuid4
from typing import List, Optional, Dict
from quart import g
from lnbits.db import open_db from lnbits import bolt11
from lnbits.settings import DEFAULT_WALLET_NAME from lnbits.settings import DEFAULT_WALLET_NAME
from .models import User, Wallet, Payment from .models import User, Wallet, Payment
@@ -12,9 +15,8 @@ from .models import User, Wallet, Payment
def create_account() -> User: def create_account() -> User:
with open_db() as db:
user_id = uuid4().hex user_id = uuid4().hex
db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,)) g.db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
new_account = get_account(user_id=user_id) new_account = get_account(user_id=user_id)
assert new_account, "Newly created account couldn't be retrieved" assert new_account, "Newly created account couldn't be retrieved"
@@ -23,19 +25,17 @@ def create_account() -> User:
def get_account(user_id: str) -> Optional[User]: def get_account(user_id: str) -> Optional[User]:
with open_db() as db: row = g.db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,))
row = db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,))
return User(**row) if row else None return User(**row) if row else None
def get_user(user_id: str) -> Optional[User]: def get_user(user_id: str) -> Optional[User]:
with open_db() as db: user = g.db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,))
user = db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,))
if user: if user:
extensions = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,)) extensions = g.db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,))
wallets = db.fetchall( wallets = g.db.fetchall(
""" """
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets FROM wallets
@@ -52,8 +52,7 @@ def get_user(user_id: str) -> Optional[User]:
def update_user_extension(*, user_id: str, extension: str, active: int) -> None: def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
with open_db() as db: g.db.execute(
db.execute(
""" """
INSERT OR REPLACE INTO extensions (user, extension, active) INSERT OR REPLACE INTO extensions (user, extension, active)
VALUES (?, ?, ?) VALUES (?, ?, ?)
@@ -67,9 +66,8 @@ def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet: def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet:
with open_db() as db:
wallet_id = uuid4().hex wallet_id = uuid4().hex
db.execute( g.db.execute(
""" """
INSERT INTO wallets (id, name, user, adminkey, inkey) INSERT INTO wallets (id, name, user, adminkey, inkey)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
@@ -84,8 +82,7 @@ def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet:
def delete_wallet(*, user_id: str, wallet_id: str) -> None: def delete_wallet(*, user_id: str, wallet_id: str) -> None:
with open_db() as db: g.db.execute(
db.execute(
""" """
UPDATE wallets AS w UPDATE wallets AS w
SET SET
@@ -99,8 +96,7 @@ def delete_wallet(*, user_id: str, wallet_id: str) -> None:
def get_wallet(wallet_id: str) -> Optional[Wallet]: def get_wallet(wallet_id: str) -> Optional[Wallet]:
with open_db() as db: row = g.db.fetchone(
row = db.fetchone(
""" """
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets FROM wallets
@@ -113,66 +109,118 @@ def get_wallet(wallet_id: str) -> Optional[Wallet]:
def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]: def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
with open_db() as db: row = g.db.fetchone(
check_field = "adminkey" if key_type == "admin" else "inkey" """
row = db.fetchone(
f"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets FROM wallets
WHERE {check_field} = ? WHERE adminkey = ? OR inkey = ?
""", """,
(key,), (key, key),
) )
return Wallet(**row) if row else None if not row:
return None
if key_type == "admin" and row["adminkey"] != key:
return None
return Wallet(**row)
# wallet payments # wallet payments
# --------------- # ---------------
def get_wallet_payment(wallet_id: str, checking_id: str) -> Optional[Payment]: def get_wallet_payment(wallet_id: str, payment_hash: str) -> Optional[Payment]:
with open_db() as db: row = g.db.fetchone(
row = db.fetchone(
""" """
SELECT payhash as checking_id, amount, fee, pending, memo, time SELECT *
FROM apipayments FROM apipayments
WHERE wallet = ? AND payhash = ? WHERE wallet = ? AND hash = ?
""", """,
(wallet_id, checking_id), (wallet_id, payment_hash),
) )
return Payment(**row) if row else None return Payment.from_row(row) if row else None
def get_wallet_payments(wallet_id: str, *, include_all_pending: bool = False) -> List[Payment]: def get_wallet_payments(
with open_db() as db: wallet_id: str,
if include_all_pending: *,
clause = "pending = 1" 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: else:
clause = "((amount > 0 AND pending = 0) OR amount < 0)" raise TypeError("at least one of [complete, pending] must be True.")
rows = db.fetchall( 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""" f"""
SELECT payhash as checking_id, amount, fee, pending, memo, time SELECT *
FROM apipayments FROM apipayments
WHERE wallet = ? AND {clause} WHERE wallet = ? {clause}
ORDER BY time DESC ORDER BY time DESC
""", """,
(wallet_id,), (wallet_id,),
) )
return [Payment(**row) for row in rows] return [Payment.from_row(row) for row in rows]
def delete_wallet_payments_expired(wallet_id: str, *, seconds: int = 86400) -> None: def delete_expired_invoices() -> None:
with open_db() as db: rows = g.db.fetchall(
db.execute(
""" """
DELETE SELECT bolt11
FROM apipayments WHERE wallet = ? AND pending = 1 AND time < strftime('%s', 'now') - ? 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 = ?
""", """,
(wallet_id, seconds), (invoice.payment_hash,),
) )
@@ -181,28 +229,68 @@ def delete_wallet_payments_expired(wallet_id: str, *, seconds: int = 86400) -> N
def create_payment( def create_payment(
*, wallet_id: str, checking_id: str, amount: int, memo: str, fee: int = 0, pending: bool = True *,
wallet_id: str,
checking_id: str,
payment_request: str,
payment_hash: str,
amount: int,
memo: str,
fee: int = 0,
preimage: Optional[str] = None,
pending: bool = True,
extra: Optional[Dict] = None,
) -> Payment: ) -> Payment:
with open_db() as db: g.db.execute(
db.execute(
""" """
INSERT INTO apipayments (wallet, payhash, amount, pending, memo, fee) INSERT INTO apipayments
VALUES (?, ?, ?, ?, ?, ?) (wallet, checking_id, bolt11, hash, preimage,
amount, pending, memo, fee, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
(wallet_id, checking_id, amount, int(pending), memo, fee), (
wallet_id,
checking_id,
payment_request,
payment_hash,
preimage,
amount,
int(pending),
memo,
fee,
json.dumps(extra) if extra and extra != {} and type(extra) is dict else None,
),
) )
new_payment = get_wallet_payment(wallet_id, checking_id) new_payment = get_wallet_payment(wallet_id, payment_hash)
assert new_payment, "Newly created payment couldn't be retrieved" assert new_payment, "Newly created payment couldn't be retrieved"
return new_payment return new_payment
def update_payment_status(checking_id: str, pending: bool) -> None: def update_payment_status(checking_id: str, pending: bool) -> None:
with open_db() as db: g.db.execute(
db.execute("UPDATE apipayments SET pending = ? WHERE payhash = ?", (int(pending), checking_id,)) "UPDATE apipayments SET pending = ? WHERE checking_id = ?",
(
int(pending),
checking_id,
),
)
def delete_payment(checking_id: str) -> None: def delete_payment(checking_id: str) -> None:
with open_db() as db: g.db.execute("DELETE FROM apipayments WHERE checking_id = ?", (checking_id,))
db.execute("DELETE FROM apipayments WHERE payhash = ?", (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"]
+51 -4
View File
@@ -1,4 +1,15 @@
from lnbits.db import open_db import sqlite3
def m000_create_migrations_table(db):
db.execute(
"""
CREATE TABLE dbversions (
db TEXT PRIMARY KEY,
version INT NOT NULL
)
"""
)
def m001_initial(db): def m001_initial(db):
@@ -51,6 +62,7 @@ def m001_initial(db):
); );
""" """
) )
db.execute( db.execute(
""" """
CREATE VIEW IF NOT EXISTS balances AS CREATE VIEW IF NOT EXISTS balances AS
@@ -70,6 +82,41 @@ def m001_initial(db):
) )
def migrate(): def m002_add_fields_to_apipayments(db):
with open_db() as db: """
m001_initial(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
+50 -11
View File
@@ -1,4 +1,6 @@
from typing import List, NamedTuple, Optional import json
from typing import List, NamedTuple, Optional, Dict
from sqlite3 import Row
class User(NamedTuple): class User(NamedTuple):
@@ -27,22 +29,32 @@ class Wallet(NamedTuple):
@property @property
def balance(self) -> int: def balance(self) -> int:
return self.balance // 1000 return self.balance_msat // 1000
def get_payment(self, checking_id: str) -> Optional["Payment"]: def get_payment(self, payment_hash: str) -> Optional["Payment"]:
from .crud import get_wallet_payment from .crud import get_wallet_payment
return get_wallet_payment(self.id, checking_id) return get_wallet_payment(self.id, payment_hash)
def get_payments(self, *, include_all_pending: bool = False) -> List["Payment"]: def get_payments(
self,
*,
complete: bool = True,
pending: bool = False,
outgoing: bool = True,
incoming: bool = True,
exclude_uncheckable: bool = False
) -> List["Payment"]:
from .crud import get_wallet_payments from .crud import get_wallet_payments
return get_wallet_payments(self.id, include_all_pending=include_all_pending) return get_wallet_payments(
self.id,
def delete_expired_payments(self, seconds: int = 86400) -> None: complete=complete,
from .crud import delete_wallet_payments_expired pending=pending,
outgoing=outgoing,
delete_wallet_payments_expired(self.id, seconds=seconds) incoming=incoming,
exclude_uncheckable=exclude_uncheckable,
)
class Payment(NamedTuple): class Payment(NamedTuple):
@@ -52,6 +64,29 @@ class Payment(NamedTuple):
fee: int fee: int
memo: str memo: str
time: int 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 @property
def msat(self) -> int: def msat(self) -> int:
@@ -69,6 +104,10 @@ class Payment(NamedTuple):
def is_out(self) -> bool: def is_out(self) -> bool:
return self.amount < 0 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: def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status from .crud import update_payment_status
+106 -52
View File
@@ -1,73 +1,127 @@
from typing import Optional, Tuple from typing import Optional, Tuple, Dict
from quart import g
from lnbits.bolt11 import decode as bolt11_decode # type: ignore try:
from typing import TypedDict # type: ignore
except ImportError: # pragma: nocover
from typing_extensions import TypedDict
from lnbits import bolt11
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import WALLET from lnbits.settings import WALLET
from lnbits.wallets.base import PaymentStatus
from .crud import get_wallet, create_payment, delete_payment from .crud import get_wallet, create_payment, delete_payment, check_internal, update_payment_status, get_wallet_payment
def create_invoice(*, wallet_id: str, amount: int, memo: str) -> Tuple[str, str]: def create_invoice(
try: *,
ok, checking_id, payment_request, error_message = WALLET.create_invoice(amount=amount, memo=memo) wallet_id: str,
except Exception as e: amount: int,
ok, error_message = False, str(e) 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: if not ok:
raise Exception(error_message or "Unexpected backend error.") raise Exception(error_message or "Unexpected backend error.")
invoice = bolt11.decode(payment_request)
amount_msat = amount * 1000 amount_msat = amount * 1000
create_payment(wallet_id=wallet_id, checking_id=checking_id, amount=amount_msat, memo=memo)
return checking_id, payment_request
def pay_invoice(*, wallet_id: str, bolt11: str, max_sat: Optional[int] = None) -> str:
temp_id = f"temp_{urlsafe_short_hash()}"
try:
invoice = bolt11_decode(bolt11)
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
if max_sat and invoice.amount_msat > max_sat * 1000:
raise ValueError("Amount in invoice is too high.")
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
create_payment(
wallet_id=wallet_id,
checking_id=temp_id,
amount=-invoice.amount_msat,
fee=-fee_reserve,
memo=temp_id,
)
wallet = get_wallet(wallet_id)
assert wallet, "invalid wallet id"
if wallet.balance_msat < 0:
raise PermissionError("Insufficient balance.")
ok, checking_id, fee_msat, error_message = WALLET.pay_invoice(bolt11)
if ok:
create_payment( create_payment(
wallet_id=wallet_id, wallet_id=wallet_id,
checking_id=checking_id, checking_id=checking_id,
amount=-invoice.amount_msat, payment_request=payment_request,
fee=fee_msat, payment_hash=invoice.payment_hash,
memo=invoice.description, amount=amount_msat,
memo=storeable_memo,
extra=extra,
) )
except Exception as e: g.db.commit()
ok, error_message = False, str(e) 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) delete_payment(temp_id)
else:
raise Exception(error_message or "Failed to pay_invoice on backend.")
if not ok: g.db.commit()
raise Exception(error_message or "Unexpected backend error.") return invoice.payment_hash
return checking_id
def check_payment(*, checking_id: str) -> str: def check_invoice_status(wallet_id: str, payment_hash: str) -> PaymentStatus:
pass payment = get_wallet_payment(wallet_id, payment_hash)
if not payment:
return PaymentStatus(None)
return WALLET.get_invoice_status(payment.checking_id)
+1 -1
View File
@@ -1,4 +1,4 @@
new Vue({ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin] mixins: [windowMixin]
}); })
+4 -4
View File
@@ -8,18 +8,18 @@ new Vue({
data: {} data: {}
}, },
walletName: '' walletName: ''
}; }
}, },
methods: { methods: {
createWallet: function () { createWallet: function () {
LNbits.href.createWallet(this.walletName); LNbits.href.createWallet(this.walletName)
}, },
processing: function () { processing: function () {
this.$q.notify({ this.$q.notify({
timeout: 0, timeout: 0,
message: 'Processing...', message: 'Processing...',
icon: null icon: null
}); })
} }
} }
}); })
+193 -127
View File
@@ -1,39 +1,51 @@
Vue.component(VueQrcode.name, VueQrcode); /* globals decode, Vue, VueQrcodeReader, VueQrcode, Quasar, LNbits, _, EventHub, Chart */
Vue.use(VueQrcodeReader);
Vue.component(VueQrcode.name, VueQrcode)
Vue.use(VueQrcodeReader)
function generateChart(canvas, payments) { function generateChart(canvas, payments) {
var txs = []; var txs = []
var n = 0; var n = 0
var data = { var data = {
labels: [], labels: [],
income: [], income: [],
outcome: [], outcome: [],
cumulative: [] cumulative: []
}; }
_.each(payments.slice(0).sort(function (a, b) { _.each(
return a.time - b.time; payments.slice(0).sort(function (a, b) {
}), function (tx) { return a.time - b.time
}),
function (tx) {
txs.push({ txs.push({
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'), hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
sat: tx.sat, sat: tx.sat
}); })
}); }
)
_.each(_.groupBy(txs, 'hour'), function (value, day) { _.each(_.groupBy(txs, 'hour'), function (value, day) {
var income = _.reduce(value, function(memo, tx) { var income = _.reduce(
return (tx.sat >= 0) ? memo + tx.sat : memo; value,
}, 0); function (memo, tx) {
var outcome = _.reduce(value, function(memo, tx) { return tx.sat >= 0 ? memo + tx.sat : memo
return (tx.sat < 0) ? memo + Math.abs(tx.sat) : memo; },
}, 0); 0
n = n + income - outcome; )
data.labels.push(day); var outcome = _.reduce(
data.income.push(income); value,
data.outcome.push(outcome); function (memo, tx) {
data.cumulative.push(n); 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'), { new Chart(canvas.getContext('2d'), {
type: 'bar', type: 'bar',
@@ -72,10 +84,11 @@ function generateChart(canvas, payments) {
}, },
tooltips: { tooltips: {
mode: 'index', mode: 'index',
intersect:false intersect: false
}, },
scales: { scales: {
xAxes: [{ xAxes: [
{
type: 'time', type: 'time',
display: true, display: true,
offset: true, offset: true,
@@ -83,7 +96,8 @@ function generateChart(canvas, payments) {
minUnit: 'hour', minUnit: 'hour',
stepSize: 3 stepSize: 3
} }
}], }
]
}, },
// performance tweaks // performance tweaks
animation: { animation: {
@@ -95,15 +109,15 @@ function generateChart(canvas, payments) {
} }
} }
} }
}); })
} }
new Vue({ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
user: LNbits.map.user(window.user),
receive: { receive: {
show: false, show: false,
status: 'pending', status: 'pending',
@@ -127,9 +141,26 @@ new Vue({
payments: [], payments: [],
paymentsTable: { paymentsTable: {
columns: [ columns: [
{name: 'memo', align: 'left', label: 'Memo', field: 'memo'}, {
{name: 'date', align: 'left', label: 'Date', field: 'date', sortable: true}, name: 'memo',
{name: 'sat', align: 'right', label: 'Amount (sat)', field: 'sat', sortable: true} 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: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@@ -143,46 +174,50 @@ new Vue({
show: false, show: false,
location: window.location location: window.location
} }
}; }
}, },
computed: { computed: {
filteredPayments: function () { filteredPayments: function () {
var q = this.paymentsTable.filter; var q = this.paymentsTable.filter
if (!q || q == '') return this.payments; if (!q || q === '') return this.payments
return LNbits.utils.search(this.payments, q); return LNbits.utils.search(this.payments, q)
}, },
balance: function () { balance: function () {
if (this.payments.length) { if (this.payments.length) {
return _.pluck(this.payments, 'amount').reduce(function (a, b) { return a + b; }, 0) / 1000; return (
_.pluck(this.payments, 'amount').reduce(function (a, b) {
return a + b
}, 0) / 1000
)
} }
return this.g.wallet.sat; return this.g.wallet.sat
}, },
fbalance: function () { fbalance: function () {
return LNbits.utils.formatSat(this.balance) return LNbits.utils.formatSat(this.balance)
}, },
canPay: function () { canPay: function () {
if (!this.send.invoice) return false; if (!this.send.invoice) return false
return this.send.invoice.sat <= this.balance; return this.send.invoice.sat <= this.balance
}, },
pendingPaymentsExist: function () { pendingPaymentsExist: function () {
return (this.payments) return this.payments
? _.where(this.payments, {pending: 1}).length > 0 ? _.where(this.payments, {pending: 1}).length > 0
: false; : false
} }
}, },
methods: { methods: {
closeCamera: function () { closeCamera: function () {
this.sendCamera.show = false; this.sendCamera.show = false
}, },
showCamera: function () { showCamera: function () {
this.sendCamera.show = true; this.sendCamera.show = true
}, },
showChart: function () { showChart: function () {
this.paymentsChart.show = true; this.paymentsChart.show = true
this.$nextTick(function () { this.$nextTick(function () {
generateChart(this.$refs.canvas, this.payments); generateChart(this.$refs.canvas, this.payments)
}); })
}, },
showReceiveDialog: function () { showReceiveDialog: function () {
this.receive = { this.receive = {
@@ -194,7 +229,7 @@ new Vue({
memo: '' memo: ''
}, },
paymentChecker: null paymentChecker: null
}; }
}, },
showSendDialog: function () { showSendDialog: function () {
this.send = { this.send = {
@@ -204,52 +239,64 @@ new Vue({
bolt11: '' bolt11: ''
}, },
paymentChecker: null paymentChecker: null
}; }
}, },
closeReceiveDialog: function () { closeReceiveDialog: function () {
var checker = this.receive.paymentChecker; var checker = this.receive.paymentChecker
setTimeout(function () { setTimeout(function () {
clearInterval(checker); clearInterval(checker)
}, 10000); }, 10000)
}, },
closeSendDialog: function () { closeSendDialog: function () {
this.sendCamera.show = false; this.sendCamera.show = false
var checker = this.send.paymentChecker; var checker = this.send.paymentChecker
setTimeout(function () { setTimeout(function () {
clearInterval(checker); clearInterval(checker)
}, 1000); }, 1000)
}, },
createInvoice: function () { createInvoice: function () {
var self = this; var self = this
this.receive.status = 'loading'; this.receive.status = 'loading'
LNbits.api.createInvoice(this.g.wallet, this.receive.data.amount, this.receive.data.memo) LNbits.api
.createInvoice(
this.g.wallet,
this.receive.data.amount,
this.receive.data.memo
)
.then(function (response) { .then(function (response) {
self.receive.status = 'success'; self.receive.status = 'success'
self.receive.paymentReq = response.data.payment_request; self.receive.paymentReq = response.data.payment_request
self.receive.paymentChecker = setInterval(function () { self.receive.paymentChecker = setInterval(function () {
LNbits.api.getPayment(self.g.wallet, response.data.checking_id).then(function (response) { LNbits.api
.getPayment(self.g.wallet, response.data.payment_hash)
.then(function (response) {
if (response.data.paid) { if (response.data.paid) {
self.fetchPayments(); self.fetchPayments()
self.receive.show = false; self.receive.show = false
clearInterval(self.receive.paymentChecker); clearInterval(self.receive.paymentChecker)
} }
}); })
}, 2000); }, 2000)
})
}).catch(function (error) { .catch(function (error) {
LNbits.utils.notifyApiError(error); LNbits.utils.notifyApiError(error)
self.receive.status = 'pending'; self.receive.status = 'pending'
}); })
}, },
decodeQR: function (res) { decodeQR: function (res) {
this.send.data.bolt11 = res; this.send.data.bolt11 = res
this.decodeInvoice(); this.decodeInvoice()
this.sendCamera.show = false; this.sendCamera.show = false
}, },
decodeInvoice: function () { decodeInvoice: function () {
if (this.send.data.bolt11.startsWith('lightning:')) {
this.send.data.bolt11 = this.send.data.bolt11.slice(10)
}
let invoice
try { try {
var invoice = decode(this.send.data.bolt11); invoice = decode(this.send.data.bolt11)
} catch (error) { } catch (error) {
this.$q.notify({ this.$q.notify({
timeout: 3000, timeout: 3000,
@@ -257,101 +304,120 @@ new Vue({
message: error + '.', message: error + '.',
caption: '400 BAD REQUEST', caption: '400 BAD REQUEST',
icon: null icon: null
}); })
return; return
} }
var cleanInvoice = { let cleanInvoice = {
msat: invoice.human_readable_part.amount, msat: invoice.human_readable_part.amount,
sat: invoice.human_readable_part.amount / 1000, sat: invoice.human_readable_part.amount / 1000,
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000) fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000)
}; }
_.each(invoice.data.tags, function (tag) { _.each(invoice.data.tags, function (tag) {
if (_.isObject(tag) && _.has(tag, 'description')) { if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description == 'payment_hash') { cleanInvoice.hash = tag.value; } if (tag.description === 'payment_hash') {
else if (tag.description == 'description') { cleanInvoice.description = tag.value; } cleanInvoice.hash = tag.value
else if (tag.description == 'expiry') { } else if (tag.description === 'description') {
var expireDate = new Date((invoice.data.time_stamp + tag.value) * 1000); cleanInvoice.description = tag.value
cleanInvoice.expireDate = Quasar.utils.date.formatDate(expireDate, 'YYYY-MM-DDTHH:mm:ss.SSSZ'); } else if (tag.description === 'expiry') {
cleanInvoice.expired = false; // TODO var expireDate = new Date(
(invoice.data.time_stamp + tag.value) * 1000
)
cleanInvoice.expireDate = Quasar.utils.date.formatDate(
expireDate,
'YYYY-MM-DDTHH:mm:ss.SSSZ'
)
cleanInvoice.expired = false // TODO
} }
} }
}); })
this.send.invoice = Object.freeze(cleanInvoice); this.send.invoice = Object.freeze(cleanInvoice)
}, },
payInvoice: function () { payInvoice: function () {
var self = this; var self = this
dismissPaymentMsg = this.$q.notify({ let dismissPaymentMsg = this.$q.notify({
timeout: 0, timeout: 0,
message: 'Processing payment...', message: 'Processing payment...',
icon: null icon: null
}); })
LNbits.api.payInvoice(this.g.wallet, this.send.data.bolt11).then(function (response) { LNbits.api
.payInvoice(this.g.wallet, this.send.data.bolt11)
.then(function (response) {
self.send.paymentChecker = setInterval(function () { self.send.paymentChecker = setInterval(function () {
LNbits.api.getPayment(self.g.wallet, response.data.checking_id).then(function (res) { LNbits.api
.getPayment(self.g.wallet, response.data.payment_hash)
.then(function (res) {
if (res.data.paid) { if (res.data.paid) {
self.send.show = false; self.send.show = false
clearInterval(self.send.paymentChecker); clearInterval(self.send.paymentChecker)
dismissPaymentMsg(); dismissPaymentMsg()
self.fetchPayments(); self.fetchPayments()
} }
}); })
}, 2000); }, 2000)
}).catch(function (error) { })
dismissPaymentMsg(); .catch(function (error) {
LNbits.utils.notifyApiError(error); dismissPaymentMsg()
}); LNbits.utils.notifyApiError(error)
})
}, },
deleteWallet: function (walletId, user) { deleteWallet: function (walletId, user) {
LNbits.utils.confirmDialog( LNbits.utils
'Are you sure you want to delete this wallet?' .confirmDialog('Are you sure you want to delete this wallet?')
).onOk(function () { .onOk(function () {
LNbits.href.deleteWallet(walletId, user); LNbits.href.deleteWallet(walletId, user)
}); })
}, },
fetchPayments: function (checkPending) { fetchPayments: function (checkPending) {
var self = this; var self = this
return LNbits.api.getPayments(this.g.wallet, checkPending).then(function (response) { return LNbits.api
self.payments = response.data.map(function (obj) { .getPayments(this.g.wallet, checkPending)
return LNbits.map.payment(obj); .then(function (response) {
}).sort(function (a, b) { self.payments = response.data
return b.time - a.time; .map(function (obj) {
}); return LNbits.map.payment(obj)
}); })
.sort(function (a, b) {
return b.time - a.time
})
})
}, },
checkPendingPayments: function () { checkPendingPayments: function () {
var dismissMsg = this.$q.notify({ var dismissMsg = this.$q.notify({
timeout: 0, timeout: 0,
message: 'Checking pending transactions...', message: 'Checking pending transactions...',
icon: null icon: null
}); })
this.fetchPayments(true).then(function () { this.fetchPayments(true).then(function () {
dismissMsg(); dismissMsg()
}); })
}, },
exportCSV: function () { exportCSV: function () {
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments); LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
} }
}, },
watch: { watch: {
'payments': function () { payments: function () {
EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance]); EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance])
} }
}, },
created: function () { created: function () {
this.fetchPayments(); this.fetchPayments()
setTimeout(this.checkPendingPayments(), 1200); setTimeout(this.checkPendingPayments(), 1200)
}, },
mounted: function () { mounted: function () {
if (this.$refs.disclaimer && !this.$q.localStorage.getItem('lnbits.disclaimerShown')) { if (
this.disclaimerDialog.show = true; this.$refs.disclaimer &&
this.$q.localStorage.set('lnbits.disclaimerShown', true); !this.$q.localStorage.getItem('lnbits.disclaimerShown')
) {
this.disclaimerDialog.show = true
this.$q.localStorage.set('lnbits.disclaimerShown', true)
} }
} }
}); })
+56 -14
View File
@@ -4,22 +4,43 @@
label="API info" label="API info"
:content-inset-level="0.5" :content-inset-level="0.5"
> >
<q-expansion-item group="api" dense expand-separator label="Create an invoice (incoming)"> <q-expansion-item
group="api"
dense
expand-separator
label="Create an invoice (incoming)"
>
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code> <code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br /> <code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}</code> <code
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5> >{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&gt;}</code
<code>{"checking_id": &lt;string&gt;, "payment_request": &lt;string&gt;}</code> >
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"payment_hash": &lt;string&gt;, "payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <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": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key: <i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code> <code
>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": false,
"amount": &lt;int&gt;, "memo": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.inkey }}</i>" -H "Content-type: application/json"</code
>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Pay an invoice (outgoing)"> <q-expansion-item
group="api"
dense
expand-separator
label="Pay an invoice (outgoing)"
>
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code> <code><span class="text-light-green">POST</span> /api/v1/payments</code>
@@ -27,24 +48,45 @@
<code>{"X-Api-Key": "{{ wallet.adminkey }}"}</code> <code>{"X-Api-Key": "{{ wallet.adminkey }}"}</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">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code> <code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">
<code>{"checking_id": &lt;string&gt;}</code> Returns 201 CREATED (application/json)
</h5>
<code>{"payment_hash": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <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": &lt;string&gt;}' -H "X-Api-Key: {{ wallet.adminkey }}"</i> -H "Content-type: application/json"</code> <code
>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": true,
"bolt11": &lt;string&gt;}' -H "X-Api-Key:
<i>{{ wallet.adminkey }}"</i> -H "Content-type:
application/json"</code
>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Check an invoice (incoming or outgoing)" <q-expansion-item
class="q-pb-md"> group="api"
dense
expand-separator
label="Check an invoice (incoming or outgoing)"
class="q-pb-md"
>
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-light-blue">GET</span> /api/v1/payments/&lt;checking_id&gt;</code> <code
><span class="text-light-blue">GET</span>
/api/v1/payments/&lt;payment_hash&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code> <code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"paid": &lt;bool&gt;}</code> <code>{"paid": &lt;bool&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X GET {{ request.url_root }}api/v1/payments/&lt;checking_id&gt; -H "X-Api-Key: {{ wallet.inkey }}"</i> -H "Content-type: application/json"</code> <code
>curl -X GET {{ request.url_root
}}api/v1/payments/&lt;payment_hash&gt; -H "X-Api-Key:
<i>{{ wallet.inkey }}"</i> -H "Content-type: application/json"</code
>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
+39 -27
View File
@@ -1,42 +1,54 @@
{% extends "base.html" %} {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user) }}
{% from "macros.jinja" import window_vars with context %} <script src="/core/static/js/extensions.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
{% block scripts %} <div
{{ window_vars(user) }} class="col-6 col-md-4 col-lg-3"
{% assets filters='rjsmin', output='__bundle__/core/extensions.js', v-for="extension in g.extensions"
'core/js/extensions.js' %} :key="extension.code"
<script type="text/javascript" src="{{ ASSET_URL }}"></script> >
{% endassets %}
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-6 col-md-4 col-lg-3" v-for="extension in g.extensions" :key="extension.code">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-icon :name="extension.icon" color="grey-5" style="font-size: 4rem;"></q-icon> <q-icon
:name="extension.icon"
color="grey-5"
style="font-size: 4rem"
></q-icon>
{% raw %} {% raw %}
<h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5> <h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5>
{{ extension.shortDescription }} {{ extension.shortDescription }} {% endraw %}
{% endraw %}
</q-card-section> </q-card-section>
<q-separator></q-separator> <q-separator></q-separator>
<q-card-actions> <q-card-actions>
<div v-if="extension.isEnabled"> <div v-if="extension.isEnabled">
<q-btn flat color="deep-purple" <q-btn
type="a" :href="[extension.url, '?usr=', g.user.id].join('')">Open</q-btn> flat
<q-btn flat color="grey-5" color="deep-purple"
type="a" type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&disable=', extension.code].join('')"> Disable</q-btn> :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> </div>
<q-btn v-else flat color="deep-purple" <q-btn
v-else
flat
color="deep-purple"
type="a" type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')"> :href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')"
Enable</q-btn> >
Enable</q-btn
>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+68 -30
View File
@@ -1,36 +1,35 @@
{% extends "public.html" %} {% extends "public.html" %} {% block scripts %}
<script src="/core/static/js/index.js"></script>
{% endblock %} {% block page %}
{% block scripts %} <div class="row q-col-gutter-md justify-center">
{% assets filters='rjsmin', output='__bundle__/core/index.js',
'core/js/index.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md"> <div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
{% if lnurl %} {% if lnurl %}
<q-btn unelevated <q-btn
unelevated
color="deep-purple" color="deep-purple"
@click="processing" @click="processing"
type="a" href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}"> type="a"
href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}"
>
Press to claim bitcoin Press to claim bitcoin
</q-btn> </q-btn>
{% else %} {% else %}
<q-form @submit="createWallet" class="q-gutter-md"> <q-form @submit="createWallet" class="q-gutter-md">
<q-input filled dense <q-input
filled
dense
v-model="walletName" v-model="walletName"
label="Name your LNbits wallet *" label="Name your LNbits wallet *"
></q-input> ></q-input>
<q-btn unelevated <q-btn
unelevated
color="deep-purple" color="deep-purple"
:disable="walletName == ''" :disable="walletName == ''"
type="submit">Add a new wallet</q-btn> type="submit"
>Add a new wallet</q-btn
>
</q-form> </q-form>
{% endif %} {% endif %}
</q-card-section> </q-card-section>
@@ -40,33 +39,72 @@
<q-card-section> <q-card-section>
<h3 class="q-my-none"><strong>LN</strong>bits</h3> <h3 class="q-my-none"><strong>LN</strong>bits</h3>
<h5 class="q-my-md">Free and open-source lightning wallet</h5> <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>
<p>You can run LNbits for yourself, or easily offer a custodian solution for others.</p> Easy to set up and lightweight, LNbits can run on any
<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> lightning-network funding source, currently supporting LND,
<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> 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"> <div class="row q-mt-md q-gutter-sm">
<q-btn outline <q-btn
outline
color="grey" color="grey"
type="a" href="https://github.com/arcbtc/lnbits" target="_blank" rel="noopener">View project in GitHub</q-btn> type="a"
<q-btn outline href="https://github.com/lnbits/lnbits"
target="_blank"
rel="noopener"
>View project in GitHub</q-btn
>
<q-btn
outline
color="grey" color="grey"
type="a" href="https://paywall.link/to/f4e4e" target="_blank" rel="noopener">Donate</q-btn> type="a"
href="https://lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK"
target="_blank"
rel="noopener"
>Donate</q-btn
>
</div> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
<!-- Ads --> <!-- Ads -->
<div class="col-12 col-md-3 col-lg-3"> <div class="col-12 col-md-3 col-lg-3">
<div class="row q-col-gutter-lg justify-center"> <div class="row q-col-gutter-lg justify-center">
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm"> <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> <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"> <a href="https://github.com/rootzoll/raspiblitz">
<q-img contain :src="($q.dark.isActive) ? '{{ url_for('static', filename='images/raspiblitz-negative.svg') }}' : '{{ url_for('static', filename='images/raspiblitz.svg') }}'" style="height: 90px;"></q-img> <q-img
contain
:src="($q.dark.isActive) ? '/static/images/raspiblitz-negative.svg' : '/static/images/raspiblitz.svg'"
style="height: 90px"
></q-img>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+240 -96
View File
@@ -1,48 +1,33 @@
{% extends "base.html" %} {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block scripts %} {{ window_vars(user, wallet) }}
{% from "macros.jinja" import window_vars with context %} <script src="/core/static/js/wallet.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md">
{% block styles %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='vendor/vue-qrcode-reader@2.1.1/vue-qrcode-reader.min.css') }}">
{% endblock %}
{% block scripts %}
{{ window_vars(user, wallet) }}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
{% assets filters='rjsmin', output='__bundle__/core/chart.js',
'vendor/moment@2.24.0/moment.min.js',
'vendor/chart.js@2.9.3/chart.min.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% assets filters='rjsmin', output='__bundle__/core/wallet.js',
'vendor/bolt11/utils.js',
'vendor/bolt11/decoder.js',
'vendor/vue-qrcode-reader@2.1.1/vue-qrcode-reader.min.js',
'core/js/wallet.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md"> <div class="col-12 col-md-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<h3 class="q-my-none"><strong>{% raw %}{{ fbalance }}{% endraw %}</strong> sat</h3> <h3 class="q-my-none">
<strong>{% raw %}{{ fbalance }}{% endraw %}</strong> sat
</h3>
</q-card-section> </q-card-section>
<div class="row q-pb-md q-px-md q-col-gutter-md"> <div class="row q-pb-md q-px-md q-col-gutter-md">
<div class="col"> <div class="col">
<q-btn unelevated <q-btn
unelevated
color="purple" color="purple"
class="full-width" class="full-width"
@click="showSendDialog">Send</q-btn> @click="showSendDialog"
>Send</q-btn
>
</div> </div>
<div class="col"> <div class="col">
<q-btn unelevated <q-btn
unelevated
color="deep-purple" color="deep-purple"
class="full-width" class="full-width"
@click="showReceiveDialog">Receive</q-btn> @click="showReceiveDialog"
>Receive</q-btn
>
</div> </div>
</div> </div>
</q-card> </q-card>
@@ -58,41 +43,74 @@
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments"> <!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
<q-tooltip>Check pending</q-tooltip> <q-tooltip>Check pending</q-tooltip>
</q-btn>--> </q-btn>-->
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart"> <q-btn
dense
flat
round
icon="show_chart"
color="grey"
@click="showChart"
>
<q-tooltip>Show chart</q-tooltip> <q-tooltip>Show chart</q-tooltip>
</q-btn> </q-btn>
</div> </div>
</div> </div>
<q-input v-if="payments.length > 10" filled dense clearable <q-input
v-if="payments.length > 10"
filled
dense
clearable
v-model="paymentsTable.filter" v-model="paymentsTable.filter"
debounce="300" placeholder="Search by memo, amount" class="q-mb-md"> debounce="300"
placeholder="Search by tag, memo, amount"
class="q-mb-md"
>
</q-input> </q-input>
<q-table dense flat <q-table
dense
flat
:data="filteredPayments" :data="filteredPayments"
row-key="payhash" row-key="payment_hash"
:columns="paymentsTable.columns" :columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"> :pagination.sync="paymentsTable.pagination"
>
{% raw %} {% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th <q-th v-for="col in props.cols" :key="col.name" :props="props"
v-for="col in props.cols" >{{ col.label }}</q-th
:key="col.name" >
:props="props">{{ col.label }}</q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width class="lnbits__q-table__icon-td"> <q-td auto-width class="lnbits__q-table__icon-td">
<q-icon v-if="props.row.isPaid" size="14px" <q-icon
:name="(props.row.sat < 0) ? 'call_made' : 'call_received'" v-if="props.row.isPaid"
:color="(props.row.sat < 0) ? 'pink' : 'green'"></q-icon> size="14px"
<q-icon v-else name="settings_ethernet" color="grey"> :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-tooltip>Pending</q-tooltip>
</q-icon> </q-icon>
</q-td> </q-td>
<q-td key="memo" :props="props"> <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 }} {{ props.row.memo }}
</q-td> </q-td>
<q-td auto-width key="date" :props="props"> <q-td auto-width key="date" :props="props">
@@ -102,6 +120,64 @@
{{ props.row.fsat }} {{ props.row.fsat }}
</q-td> </q-td>
</q-tr> </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> </template>
{% endraw %} {% endraw %}
</q-table> </q-table>
@@ -112,11 +188,13 @@
<div class="col-12 col-md-5 q-gutter-y-md"> <div class="col-12 col-md-5 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn flat color="grey" @click="exportCSV" class="float-right">Renew keys</q-btn> <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> <h6 class="text-subtitle1 q-mt-none q-mb-sm">LNbits wallet</h6>
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br> <strong>Wallet name: </strong><em>{{ wallet.name }}</em><br />
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br> <strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br />
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br> <strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br />
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em> <strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
@@ -127,13 +205,20 @@
<q-expansion-item <q-expansion-item
group="extras" group="extras"
icon="remove_circle" icon="remove_circle"
label="Delete wallet"> label="Delete wallet"
>
<q-card> <q-card>
<q-card-section> <q-card-section>
<p>This whole wallet will be deleted, the funds will be <strong>UNRECOVERABLE</strong>.</p> <p>
<q-btn unelevated This whole wallet will be deleted, the funds will be
<strong>UNRECOVERABLE</strong>.
</p>
<q-btn
unelevated
color="red-10" color="red-10"
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')">Delete wallet</q-btn> @click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')"
>Delete wallet</q-btn
>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
@@ -141,52 +226,81 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
</div> </div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog"> <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
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<q-form @submit="createInvoice" class="q-gutter-md"> <q-form @submit="createInvoice" class="q-gutter-md">
<q-input filled dense <q-input
filled
dense
v-model.number="receive.data.amount" v-model.number="receive.data.amount"
type="number" type="number"
label="Amount (sat) *"></q-input> label="Amount (sat) *"
<q-input filled dense ></q-input>
<q-input
filled
dense
v-model.trim="receive.data.memo" v-model.trim="receive.data.memo"
label="Memo" label="Memo"
placeholder="LNbits invoice"></q-input> placeholder="LNbits invoice"
></q-input>
<div v-if="receive.status == 'pending'" class="row q-mt-lg"> <div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn unelevated <q-btn
unelevated
color="deep-purple" color="deep-purple"
:disable="receive.data.amount == null || receive.data.amount <= 0" :disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit">Create invoice</q-btn> type="submit"
>Create invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div> </div>
<q-spinner v-if="receive.status == 'loading'" color="deep-purple" size="2.55em"></q-spinner> <q-spinner
v-if="receive.status == 'loading'"
color="deep-purple"
size="2.55em"
></q-spinner>
</q-form> </q-form>
</q-card> </q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq"> <a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl"> <q-responsive :ratio="1" class="q-mx-xl">
<qrcode :value="receive.paymentReq" :options="{width: 340}" class="rounded-borders"></qrcode> <qrcode
:value="receive.paymentReq"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive> </q-responsive>
</a> </a>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)">Copy invoice</q-btn> <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> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="send.show" position="top" @hide="closeSendDialog"> <q-dialog v-model="send.show" position="top" @hide="closeSendDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!send.invoice"> <div v-if="!send.invoice">
<q-form v-if="!sendCamera.show" @submit="decodeInvoice" class="q-gutter-md"> <q-form
<q-input filled dense v-if="!sendCamera.show"
@submit="decodeInvoice"
class="q-gutter-md"
>
<q-input
filled
dense
v-model.trim="send.data.bolt11" v-model.trim="send.data.bolt11"
type="textarea" type="textarea"
label="Paste an invoice *"> label="Paste an invoice *"
>
<template v-slot:after> <template v-slot:after>
<q-btn round dense flat icon="photo_camera" @click="showCamera"> <q-btn round dense flat icon="photo_camera" @click="showCamera">
<q-tooltip>Use camera to scan an invoice</q-tooltip> <q-tooltip>Use camera to scan an invoice</q-tooltip>
@@ -194,19 +308,29 @@
</template> </template>
</q-input> </q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated <q-btn
unelevated
color="deep-purple" color="deep-purple"
:disable="send.data.bolt11 == ''" :disable="send.data.bolt11 == ''"
type="submit">Read invoice</q-btn> type="submit"
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn> >Read invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div> </div>
</q-form> </q-form>
<div v-else> <div v-else>
<q-responsive :ratio="1"> <q-responsive :ratio="1">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream> <qrcode-stream
@decode="decodeQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive> </q-responsive>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">Cancel</q-btn> <q-btn @click="closeCamera" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div> </div>
</div> </div>
</div> </div>
@@ -215,45 +339,65 @@
<h6 class="q-my-none">{{ send.invoice.fsat }} sat</h6> <h6 class="q-my-none">{{ send.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator> <q-separator class="q-my-sm"></q-separator>
<p style="word-break: break-all"> <p style="word-break: break-all">
<strong>Memo:</strong> {{ send.invoice.description }}<br> <strong>Memo:</strong> {{ send.invoice.description }}<br />
<strong>Expire date:</strong> {{ send.invoice.expireDate }}<br> <strong>Expire date:</strong> {{ send.invoice.expireDate }}<br />
<strong>Hash:</strong> {{ send.invoice.hash }} <strong>Hash:</strong> {{ send.invoice.hash }}
</p> </p>
{% endraw %} {% endraw %}
<div v-if="canPay" class="row q-mt-lg"> <div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated <q-btn unelevated color="deep-purple" @click="payInvoice"
color="deep-purple" >Send satoshis</q-btn
@click="payInvoice">Send satoshis</q-btn> >
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div> </div>
<div v-else class="row q-mt-lg"> <div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black">Not enough funds!</q-btn> <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> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div> </div>
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="paymentsChart.show" position="top"> <q-dialog v-model="paymentsChart.show" position="top">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset"> <q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section> <q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas> <canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
{% if service_fee > 0 %} {% if service_fee > 0 %}
<div ref="disclaimer"></div> <div ref="disclaimer"></div>
<q-dialog v-model="disclaimerDialog.show"> <q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg"> <q-card class="q-pa-lg">
<h6 class="q-my-md text-deep-purple">Warning</h6> <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>
<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> 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"> <div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(disclaimerDialog.location.href)">Copy wallet URL</q-btn> <q-btn
<q-btn v-close-popup flat color="grey" class="q-ml-auto">I understand</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> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
{% endif %} {% endif %} {% endblock %}
{% endblock %}
+69 -34
View File
@@ -1,89 +1,124 @@
from flask import g, jsonify, request 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 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.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.helpers import Status
from lnbits.settings import WALLET from lnbits.settings import WALLET
from ..services import create_invoice, pay_invoice
@core_app.route("/api/v1/payments", methods=["GET"]) @core_app.route("/api/v1/payments", methods=["GET"])
@api_check_wallet_key("invoice") @api_check_wallet_key("invoice")
def api_payments(): async def api_payments():
if "check_pending" in request.args: if "check_pending" in request.args:
g.wallet.delete_expired_payments() delete_expired_invoices()
for payment in g.wallet.get_payments(include_all_pending=True): for payment in g.wallet.get_payments(complete=False, pending=True, exclude_uncheckable=True):
if payment.is_out: if payment.is_out:
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending) payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
else: else:
payment.set_pending(WALLET.get_invoice_status(payment.checking_id).pending) payment.set_pending(WALLET.get_invoice_status(payment.checking_id).pending)
return jsonify(g.wallet.get_payments()), Status.OK return jsonify(g.wallet.get_payments()), HTTPStatus.OK
@api_check_wallet_key("invoice") @api_check_wallet_key("invoice")
@api_validate_post_request( @api_validate_post_request(
schema={ schema={
"amount": {"type": "integer", "min": 1, "required": True}, "amount": {"type": "integer", "min": 1, "required": True},
"memo": {"type": "string", "empty": False, "required": True}, "memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
} }
) )
def api_payments_create_invoice(): async def api_payments_create_invoice():
if "description_hash" in g.data:
description_hash = unhexlify(g.data["description_hash"])
memo = ""
else:
description_hash = b""
memo = g.data["memo"]
try: try:
checking_id, payment_request = create_invoice( payment_hash, payment_request = create_invoice(
wallet_id=g.wallet.id, amount=g.data["amount"], memo=g.data["memo"] wallet_id=g.wallet.id, amount=g.data["amount"], memo=memo, description_hash=description_hash
) )
except Exception as e: except Exception as e:
return jsonify({"message": str(e)}), Status.INTERNAL_SERVER_ERROR g.db.rollback()
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), Status.CREATED 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_check_wallet_key("admin")
@api_validate_post_request(schema={"bolt11": {"type": "string", "empty": False, "required": True}}) @api_validate_post_request(schema={"bolt11": {"type": "string", "empty": False, "required": True}})
def api_payments_pay_invoice(): async def api_payments_pay_invoice():
try: try:
checking_id = pay_invoice(wallet_id=g.wallet.id, bolt11=g.data["bolt11"]) payment_hash = pay_invoice(wallet_id=g.wallet.id, payment_request=g.data["bolt11"])
except ValueError as e: except ValueError as e:
return jsonify({"message": str(e)}), Status.BAD_REQUEST return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST
except PermissionError as e: except PermissionError as e:
return jsonify({"message": str(e)}), Status.FORBIDDEN return jsonify({"message": str(e)}), HTTPStatus.FORBIDDEN
except Exception as e: except Exception as e:
return jsonify({"message": str(e)}), Status.INTERNAL_SERVER_ERROR print(e)
g.db.rollback()
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
return jsonify({"checking_id": checking_id}), Status.CREATED return (
jsonify(
{
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
),
HTTPStatus.CREATED,
)
@core_app.route("/api/v1/payments", methods=["POST"]) @core_app.route("/api/v1/payments", methods=["POST"])
@api_validate_post_request(schema={"out": {"type": "boolean", "required": True}}) @api_validate_post_request(schema={"out": {"type": "boolean", "required": True}})
def api_payments_create(): async def api_payments_create():
if g.data["out"] is True: if g.data["out"] is True:
return api_payments_pay_invoice() return await api_payments_pay_invoice()
return api_payments_create_invoice() return await api_payments_create_invoice()
@core_app.route("/api/v1/payments/<checking_id>", methods=["GET"]) @core_app.route("/api/v1/payments/<payment_hash>", methods=["GET"])
@api_check_wallet_key("invoice") @api_check_wallet_key("invoice")
def api_payment(checking_id): async def api_payment(payment_hash):
payment = g.wallet.get_payment(checking_id) payment = g.wallet.get_payment(payment_hash)
if not payment: if not payment:
return jsonify({"message": "Payment does not exist."}), Status.NOT_FOUND return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
elif not payment.pending: elif not payment.pending:
return jsonify({"paid": True}), Status.OK return jsonify({"paid": True}), HTTPStatus.OK
try: try:
if payment.is_out: if payment.is_uncheckable:
is_paid = not WALLET.get_payment_status(checking_id).pending pass
elif payment.is_out:
is_paid = not WALLET.get_payment_status(payment.checking_id).pending
elif payment.is_in: elif payment.is_in:
is_paid = not WALLET.get_invoice_status(checking_id).pending is_paid = not WALLET.get_invoice_status(payment.checking_id).pending
except Exception: except Exception:
return jsonify({"paid": False}), Status.OK return jsonify({"paid": False}), HTTPStatus.OK
if is_paid: if is_paid:
payment.set_pending(False) payment.set_pending(False)
return jsonify({"paid": True}), Status.OK return jsonify({"paid": True}), HTTPStatus.OK
return jsonify({"paid": False}), Status.OK return jsonify({"paid": False}), HTTPStatus.OK
+21 -16
View File
@@ -1,10 +1,10 @@
from flask import g, abort, redirect, request, render_template, send_from_directory, url_for from quart import g, abort, redirect, request, render_template, send_from_directory, url_for
from http import HTTPStatus
from os import path from os import path
from lnbits.core import core_app from lnbits.core import core_app
from lnbits.decorators import check_user_exists, validate_uuids from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.helpers import Status from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE
from lnbits.settings import SERVICE_FEE
from ..crud import ( from ..crud import (
create_account, create_account,
@@ -16,36 +16,36 @@ from ..crud import (
@core_app.route("/favicon.ico") @core_app.route("/favicon.ico")
def favicon(): async def favicon():
return send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico") return await send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
@core_app.route("/") @core_app.route("/")
def home(): async def home():
return render_template("core/index.html", lnurl=request.args.get("lightning", None)) return await render_template("core/index.html", lnurl=request.args.get("lightning", None))
@core_app.route("/extensions") @core_app.route("/extensions")
@validate_uuids(["usr"], required=True) @validate_uuids(["usr"], required=True)
@check_user_exists() @check_user_exists()
def extensions(): async def extensions():
extension_to_enable = request.args.get("enable", type=str) extension_to_enable = request.args.get("enable", type=str)
extension_to_disable = request.args.get("disable", type=str) extension_to_disable = request.args.get("disable", type=str)
if extension_to_enable and extension_to_disable: if extension_to_enable and extension_to_disable:
abort(Status.BAD_REQUEST, "You can either `enable` or `disable` an extension.") abort(HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension.")
if extension_to_enable: if extension_to_enable:
update_user_extension(user_id=g.user.id, extension=extension_to_enable, active=1) update_user_extension(user_id=g.user.id, extension=extension_to_enable, active=1)
elif extension_to_disable: elif extension_to_disable:
update_user_extension(user_id=g.user.id, extension=extension_to_disable, active=0) update_user_extension(user_id=g.user.id, extension=extension_to_disable, active=0)
return render_template("core/extensions.html", user=get_user(g.user.id)) return await render_template("core/extensions.html", user=get_user(g.user.id))
@core_app.route("/wallet") @core_app.route("/wallet")
@validate_uuids(["usr", "wal"]) @validate_uuids(["usr", "wal"])
def wallet(): async def wallet():
user_id = request.args.get("usr", type=str) user_id = request.args.get("usr", type=str)
wallet_id = request.args.get("wal", type=str) wallet_id = request.args.get("wal", type=str)
wallet_name = request.args.get("nme", type=str) wallet_name = request.args.get("nme", type=str)
@@ -60,7 +60,10 @@ def wallet():
if not user_id: if not user_id:
user = get_user(create_account().id) user = get_user(create_account().id)
else: else:
user = get_user(user_id) or abort(Status.NOT_FOUND, "User does not exist.") 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 not wallet_id:
if user.wallets and not wallet_name: if user.wallets and not wallet_name:
@@ -71,20 +74,22 @@ def wallet():
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id)) return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
if wallet_id not in user.wallet_ids: if wallet_id not in user.wallet_ids:
abort(Status.FORBIDDEN, "Not your wallet.") abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
return render_template("core/wallet.html", user=user, wallet=user.get_wallet(wallet_id), service_fee=service_fee) return await render_template(
"core/wallet.html", user=user, wallet=user.get_wallet(wallet_id), service_fee=service_fee
)
@core_app.route("/deletewallet") @core_app.route("/deletewallet")
@validate_uuids(["usr", "wal"], required=True) @validate_uuids(["usr", "wal"], required=True)
@check_user_exists() @check_user_exists()
def deletewallet(): async def deletewallet():
wallet_id = request.args.get("wal", type=str) wallet_id = request.args.get("wal", type=str)
user_wallet_ids = g.user.wallet_ids user_wallet_ids = g.user.wallet_ids
if wallet_id not in user_wallet_ids: if wallet_id not in user_wallet_ids:
abort(Status.FORBIDDEN, "Not your wallet.") abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
else: else:
delete_wallet(user_id=g.user.id, wallet_id=wallet_id) delete_wallet(user_id=g.user.id, wallet_id=wallet_id)
user_wallet_ids.remove(wallet_id) user_wallet_ids.remove(wallet_id)
+17 -8
View File
@@ -1,25 +1,30 @@
import requests import requests
from flask import abort, redirect, request, url_for 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 import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore
from lnurl.exceptions import LnurlException # type: ignore from lnurl.exceptions import LnurlException # type: ignore
from time import sleep
from lnbits import bolt11
from lnbits.core import core_app from lnbits.core import core_app
from lnbits.helpers import Status
from lnbits.settings import WALLET from lnbits.settings import WALLET
from ..crud import create_account, get_user, create_wallet, create_payment from ..crud import create_account, get_user, create_wallet, create_payment
@core_app.route("/lnurlwallet") @core_app.route("/lnurlwallet")
def lnurlwallet(): async def lnurlwallet():
memo = "LNbits LNURL funding" memo = "LNbits LNURL funding"
try: try:
withdraw_res = handle_lnurl(request.args.get("lightning"), response_class=LnurlWithdrawResponse) 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: except LnurlException:
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.") abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process LNURL-withdraw.")
try: try:
ok, checking_id, payment_request, error_message = WALLET.create_invoice(withdraw_res.max_sats, memo) ok, checking_id, payment_request, error_message = WALLET.create_invoice(withdraw_res.max_sats, memo)
@@ -27,7 +32,7 @@ def lnurlwallet():
ok, error_message = False, str(e) ok, error_message = False, str(e)
if not ok: if not ok:
abort(Status.INTERNAL_SERVER_ERROR, error_message) abort(HTTPStatus.INTERNAL_SERVER_ERROR, error_message)
r = requests.get( r = requests.get(
withdraw_res.callback.base, withdraw_res.callback.base,
@@ -35,7 +40,9 @@ def lnurlwallet():
) )
if not r.ok: if not r.ok:
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.") abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process LNURL-withdraw.")
inv = bolt11.decode(payment_request)
for i in range(10): for i in range(10):
invoice_status = WALLET.get_invoice_status(checking_id) invoice_status = WALLET.get_invoice_status(checking_id)
@@ -52,6 +59,8 @@ def lnurlwallet():
amount=withdraw_res.max_sats * 1000, amount=withdraw_res.max_sats * 1000,
memo=memo, memo=memo,
pending=invoice_status.pending, 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)) return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
+18 -3
View File
@@ -15,22 +15,37 @@ class Database:
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
if exc_val:
self.connection.rollback()
self.cursor.close()
self.cursor.close()
else:
self.connection.commit()
self.cursor.close() self.cursor.close()
self.connection.close() self.connection.close()
def commit(self):
self.connection.commit()
def rollback(self):
self.connection.rollback()
def fetchall(self, query: str, values: tuple = ()) -> list: def fetchall(self, query: str, values: tuple = ()) -> list:
"""Given a query, return cursor.fetchall() rows.""" """Given a query, return cursor.fetchall() rows."""
self.cursor.execute(query, values) self.execute(query, values)
return self.cursor.fetchall() return self.cursor.fetchall()
def fetchone(self, query: str, values: tuple = ()): def fetchone(self, query: str, values: tuple = ()):
self.cursor.execute(query, values) self.execute(query, values)
return self.cursor.fetchone() 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.""" """Given a query, cursor.execute() it."""
try:
self.cursor.execute(query, values) self.cursor.execute(query, values)
self.connection.commit() except sqlite3.Error as exc:
self.connection.rollback()
raise exc
def open_db(db_name: str = "database") -> Database: def open_db(db_name: str = "database") -> Database:
+33 -18
View File
@@ -1,26 +1,30 @@
from cerberus import Validator # type: ignore from cerberus import Validator # type: ignore
from flask import g, abort, jsonify, request from quart import g, abort, jsonify, request
from functools import wraps from functools import wraps
from http import HTTPStatus
from typing import List, Union from typing import List, Union
from uuid import UUID from uuid import UUID
from lnbits.core.crud import get_user, get_wallet_for_key from lnbits.core.crud import get_user, get_wallet_for_key
from .helpers import Status from lnbits.settings import LNBITS_ALLOWED_USERS
def api_check_wallet_key(key_type: str = "invoice"): def api_check_wallet_key(key_type: str = "invoice"):
def wrap(view): def wrap(view):
@wraps(view) @wraps(view)
def wrapped_view(**kwargs): async def wrapped_view(**kwargs):
try: try:
g.wallet = get_wallet_for_key(request.headers["X-Api-Key"], key_type) g.wallet = get_wallet_for_key(request.headers["X-Api-Key"], key_type)
except KeyError: except KeyError:
return jsonify({"message": "`X-Api-Key` header missing."}), Status.BAD_REQUEST return (
jsonify({"message": "`X-Api-Key` header missing."}),
HTTPStatus.BAD_REQUEST,
)
if not g.wallet: if not g.wallet:
return jsonify({"message": "Wrong keys."}), Status.UNAUTHORIZED return jsonify({"message": "Wrong keys."}), HTTPStatus.UNAUTHORIZED
return view(**kwargs) return await view(**kwargs)
return wrapped_view return wrapped_view
@@ -30,17 +34,24 @@ def api_check_wallet_key(key_type: str = "invoice"):
def api_validate_post_request(*, schema: dict): def api_validate_post_request(*, schema: dict):
def wrap(view): def wrap(view):
@wraps(view) @wraps(view)
def wrapped_view(**kwargs): async def wrapped_view(**kwargs):
if "application/json" not in request.headers["Content-Type"]: if "application/json" not in request.headers["Content-Type"]:
return jsonify({"message": "Content-Type must be `application/json`."}), Status.BAD_REQUEST return (
jsonify({"message": "Content-Type must be `application/json`."}),
HTTPStatus.BAD_REQUEST,
)
v = Validator(schema) v = Validator(schema)
g.data = {key: request.json[key] for key in schema.keys()} data = await request.get_json()
g.data = {key: data[key] for key in schema.keys() if key in data}
if not v.validate(g.data): if not v.validate(g.data):
return jsonify({"message": f"Errors in request data: {v.errors}"}), Status.BAD_REQUEST return (
jsonify({"message": f"Errors in request data: {v.errors}"}),
HTTPStatus.BAD_REQUEST,
)
return view(**kwargs) return await view(**kwargs)
return wrapped_view return wrapped_view
@@ -50,9 +61,13 @@ def api_validate_post_request(*, schema: dict):
def check_user_exists(param: str = "usr"): def check_user_exists(param: str = "usr"):
def wrap(view): def wrap(view):
@wraps(view) @wraps(view)
def wrapped_view(**kwargs): async def wrapped_view(**kwargs):
g.user = get_user(request.args.get(param, type=str)) or abort(Status.NOT_FOUND, "User not found.") g.user = get_user(request.args.get(param, type=str)) or abort(HTTPStatus.NOT_FOUND, "User does not exist.")
return view(**kwargs)
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 wrapped_view
@@ -62,20 +77,20 @@ def check_user_exists(param: str = "usr"):
def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4): def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4):
def wrap(view): def wrap(view):
@wraps(view) @wraps(view)
def wrapped_view(**kwargs): async def wrapped_view(**kwargs):
query_params = {param: request.args.get(param, type=str) for param in params} query_params = {param: request.args.get(param, type=str) for param in params}
for param, value in query_params.items(): for param, value in query_params.items():
if not value and (required is True or (required and param in required)): if not value and (required is True or (required and param in required)):
abort(Status.BAD_REQUEST, f"`{param}` is required.") abort(HTTPStatus.BAD_REQUEST, f"`{param}` is required.")
if value: if value:
try: try:
UUID(value, version=version) UUID(value, version=version)
except ValueError: except ValueError:
abort(Status.BAD_REQUEST, f"`{param}` is not a valid UUID.") abort(HTTPStatus.BAD_REQUEST, f"`{param}` is not a valid UUID.")
return view(**kwargs) return await view(**kwargs)
return wrapped_view return wrapped_view
+1 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint from quart import Blueprint
amilk_ext: Blueprint = Blueprint("amilk", __name__, static_folder="static", template_folder="templates") amilk_ext: Blueprint = Blueprint("amilk", __name__, static_folder="static", template_folder="templates")
+1 -1
View File
@@ -9,7 +9,7 @@ from .models import AMilk
def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk: def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk:
with open_ext_db("amilk") as db: with open_ext_db("amilk") as db:
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8') amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute( db.execute(
""" """
INSERT INTO amilks (id, wallet, lnurl, atime, amount) INSERT INTO amilks (id, wallet, lnurl, atime, amount)
+4 -10
View File
@@ -1,11 +1,9 @@
from lnbits.db import open_ext_db
def m001_initial(db): def m001_initial(db):
""" """
Initial amilks table. Initial amilks table.
""" """
db.execute(""" db.execute(
"""
CREATE TABLE IF NOT EXISTS amilks ( CREATE TABLE IF NOT EXISTS amilks (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
@@ -13,9 +11,5 @@ def m001_initial(db):
atime INTEGER NOT NULL, atime INTEGER NOT NULL,
amount INTEGER NOT NULL amount INTEGER NOT NULL
); );
""") """
)
def migrate():
with open_ext_db("amilk") as db:
m001_initial(db)
@@ -1,16 +1,24 @@
<q-expansion-item
<q-expansion-item
group="extras" group="extras"
icon="swap_vertical_circle" icon="swap_vertical_circle"
label="Info" label="Info"
:content-inset-level="0.5" :content-inset-level="0.5"
> >
<q-card> <q-card>
<q-card-section> <q-card-section>
<h5 class="text-subtitle1 q-my-none">Assistant Faucet Milker</h5> <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/> <p>
<small> Created by, <a href="https://github.com/benarc">Ben Arc</a></small></p> Milking faucets with software, known as "assmilking", seems at first to
</q-card> 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-section>
</q-card>
</q-card-section></q-expansion-item> </q-expansion-item>
+121 -101
View File
@@ -1,14 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
{% from "macros.jinja" import window_vars with context %} <div class="row q-col-gutter-md">
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="deep-purple" @click="amilkDialog.show = true">New AMilk</q-btn> <q-btn unelevated color="deep-purple" @click="amilkDialog.show = true"
>New AMilk</q-btn
>
</q-card-section> </q-card-section>
</q-card> </q-card>
@@ -22,38 +20,36 @@
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn> <q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div> </div>
</div> </div>
<q-table dense flat <q-table
dense
flat
:data="amilks" :data="amilks"
row-key="id" row-key="id"
:columns="amilksTable.columns" :columns="amilksTable.columns"
:pagination.sync="amilksTable.pagination"> :pagination.sync="amilksTable.pagination"
>
{% raw %} {% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.label }} {{ col.label }}
</q-th> </q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.value }} {{ col.value }}
</q-td> </q-td>
<q-td auto-width> <q-td auto-width>
<q-btn flat dense size="xs" @click="deleteAMilk(props.row.id)" icon="cancel" color="pink"></q-btn> <q-btn
flat
dense
size="xs"
@click="deleteAMilk(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>
@@ -66,13 +62,13 @@
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md"> <div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits Assistant Faucet Milker Extension</h6> <h6 class="text-subtitle1 q-my-none">
LNbits Assistant Faucet Milker Extension
</h6>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<q-separator></q-separator> <q-separator></q-separator>
<q-list> <q-list> {% include "amilk/_api_docs.html" %} </q-list>
{% include "amilk/_api_docs.html" %}
</q-list>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
@@ -80,40 +76,59 @@
<q-dialog v-model="amilkDialog.show" position="top"> <q-dialog v-model="amilkDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createAMilk" class="q-gutter-md"> <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
filled
dense
emit-value
v-model="amilkDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select> </q-select>
<q-input filled dense <q-input
filled
dense
v-model.trim="amilkDialog.data.lnurl" v-model.trim="amilkDialog.data.lnurl"
type="url" type="url"
label="LNURL Withdraw"></q-input> label="LNURL Withdraw"
<q-input filled dense ></q-input>
<q-input
filled
dense
v-model.number="amilkDialog.data.amount" v-model.number="amilkDialog.data.amount"
type="number" type="number"
label="Amount *"></q-input> label="Amount *"
<q-input filled dense ></q-input>
<q-input
filled
dense
v-model.trim="amilkDialog.data.atime" v-model.trim="amilkDialog.data.atime"
type="number" type="number"
label="Hit frequency (secs)" label="Hit frequency (secs)"
placeholder="Frequency to be hit"></q-input> placeholder="Frequency to be hit"
<q-btn unelevated ></q-input>
<q-btn
unelevated
color="deep-purple" color="deep-purple"
:disable="amilkDialog.data.amount == null || amilkDialog.data.amount < 0 || amilkDialog.data.lnurl == null" :disable="amilkDialog.data.amount == null || amilkDialog.data.amount < 0 || amilkDialog.data.lnurl == null"
type="submit">Create amilk</q-btn> type="submit"
>Create amilk</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
{% endblock %} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
{% block scripts %}
{{ window_vars(user) }}
<script>
var mapAMilk = function (obj) { var mapAMilk = function (obj) {
obj.date = Quasar.utils.date.formatDate(new Date(obj.time * 1000), 'YYYY-MM-DD HH:mm'); obj.date = Quasar.utils.date.formatDate(
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount); new Date(obj.time * 1000),
obj.wall = ['/amilk/', obj.id].join(''); 'YYYY-MM-DD HH:mm'
return obj; )
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.wall = ['/amilk/', obj.id].join('')
return obj
} }
new Vue({ new Vue({
@@ -137,94 +152,99 @@
show: false, show: false,
data: {} data: {}
} }
}; }
}, },
methods: { methods: {
getAMilks: function () { getAMilks: function () {
var self = this; var self = this
LNbits.api.request( LNbits.api
.request(
'GET', 'GET',
'/amilk/api/v1/amilk?all_wallets', '/amilk/api/v1/amilk?all_wallets',
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
).then(function (response) { )
.then(function (response) {
self.amilks = response.data.map(function (obj) { self.amilks = response.data.map(function (obj) {
response.data.forEach(MILK); response.data.forEach(MILK)
function MILK(item){ function MILK(item) {
window.setInterval(function () {
window.setInterval(function(){ LNbits.api
.request(
LNbits.api.request(
'GET', 'GET',
'/amilk/api/v1/amilk/milk/' + item.id, '/amilk/api/v1/amilk/milk/' + item.id,
"Lorem" 'Lorem'
).then(function (response) { )
.then(function (response) {
self.amilks = response.data.map(function (obj) { self.amilks = response.data.map(function (obj) {
return mapAMilk(obj); return mapAMilk(obj)
}); })
}); })
}, }, item.atime * 1000)
item.atime*1000);
} }
return mapAMilk(obj); return mapAMilk(obj)
}); })
}); })
}, },
createAMilk: function () { createAMilk: function () {
var data = { var data = {
lnurl: this.amilkDialog.data.lnurl, lnurl: this.amilkDialog.data.lnurl,
atime: parseInt(this.amilkDialog.data.atime), atime: parseInt(this.amilkDialog.data.atime),
amount: this.amilkDialog.data.amount amount: this.amilkDialog.data.amount
}; }
var self = this; var self = this
console.log(this.amilkDialog.data.wallet); console.log(this.amilkDialog.data.wallet)
LNbits.api.request( LNbits.api
.request(
'POST', 'POST',
'/amilk/api/v1/amilk', '/amilk/api/v1/amilk',
_.findWhere(this.g.user.wallets, {id: this.amilkDialog.data.wallet}).inkey, _.findWhere(this.g.user.wallets, {id: this.amilkDialog.data.wallet})
.inkey,
data data
).then(function (response) { )
self.amilks.push(mapAMilk(response.data)); .then(function (response) {
self.amilkDialog.show = false; self.amilks.push(mapAMilk(response.data))
self.amilkDialog.data = {}; self.amilkDialog.show = false
}).catch(function (error) { self.amilkDialog.data = {}
LNbits.utils.notifyApiError(error); })
}); .catch(function (error) {
LNbits.utils.notifyApiError(error)
})
}, },
deleteAMilk: function (amilkId) { deleteAMilk: function (amilkId) {
var self = this; var self = this
var amilk = _.findWhere(this.amilks, {id: amilkId}); var amilk = _.findWhere(this.amilks, {id: amilkId})
LNbits.utils.confirmDialog( LNbits.utils
'Are you sure you want to delete this AMilk link?' .confirmDialog('Are you sure you want to delete this AMilk link?')
).onOk(function () { .onOk(function () {
LNbits.api.request( LNbits.api
.request(
'DELETE', 'DELETE',
'/amilk/api/v1/amilks/' + amilkId, '/amilk/api/v1/amilks/' + amilkId,
_.findWhere(self.g.user.wallets, {id: amilk.wallet}).inkey _.findWhere(self.g.user.wallets, {id: amilk.wallet}).inkey
).then(function (response) { )
self.amilks = _.reject(self.amilks, function (obj) { return obj.id == amilkId; }); .then(function (response) {
}).catch(function (error) { self.amilks = _.reject(self.amilks, function (obj) {
LNbits.utils.notifyApiError(error); return obj.id == amilkId
}); })
}); })
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
}, },
exportCSV: function () { exportCSV: function () {
LNbits.utils.exportCSV(this.amilksTable.columns, this.amilks); LNbits.utils.exportCSV(this.amilksTable.columns, this.amilks)
} }
}, },
created: function () { created: function () {
if (this.g.user.wallets.length) { if (this.g.user.wallets.length) {
this.getAMilks(); this.getAMilks()
} }
} }
}); })
</script> </script>
{% endblock %} {% endblock %}
+7 -8
View File
@@ -1,8 +1,8 @@
from flask import g, abort, render_template from quart import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.amilk import amilk_ext from lnbits.extensions.amilk import amilk_ext
from lnbits.helpers import Status
from .crud import get_amilk from .crud import get_amilk
@@ -10,12 +10,11 @@ from .crud import get_amilk
@amilk_ext.route("/") @amilk_ext.route("/")
@validate_uuids(["usr"], required=True) @validate_uuids(["usr"], required=True)
@check_user_exists() @check_user_exists()
def index(): async def index():
return render_template("amilk/index.html", user=g.user) return await render_template("amilk/index.html", user=g.user)
@amilk_ext.route("/<amilk_id>") @amilk_ext.route("/<amilk_id>")
def wall(amilk_id): async def wall(amilk_id):
amilk = get_amilk(amilk_id) or abort(Status.NOT_FOUND, "AMilk does not exist.") amilk = get_amilk(amilk_id) or abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.")
return await render_template("amilk/wall.html", amilk=amilk)
return render_template("amilk/wall.html", amilk=amilk)
+34 -40
View File
@@ -1,48 +1,42 @@
from flask import g, jsonify, request import requests
from quart import g, jsonify, request, abort
from lnbits.core.crud import get_user from http import HTTPStatus
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.helpers import Status
from lnbits.extensions.amilk import amilk_ext
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
from lnbits.core.services import create_invoice
from flask import abort, redirect, request, url_for
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl from lnurl import LnurlWithdrawResponse, handle as handle_lnurl
from lnurl.exceptions import LnurlException from lnurl.exceptions import LnurlException
from time import sleep from time import sleep
import requests
from lnbits.settings import WALLET from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.core.services import create_invoice, check_invoice_status
from lnbits.extensions.amilk import amilk_ext
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
@amilk_ext.route("/api/v1/amilk", methods=["GET"]) @amilk_ext.route("/api/v1/amilk", methods=["GET"])
@api_check_wallet_key("invoice") @api_check_wallet_key("invoice")
def api_amilks(): async def api_amilks():
wallet_ids = [g.wallet.id] wallet_ids = [g.wallet.id]
if "all_wallets" in request.args: if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([amilk._asdict() for amilk in get_amilks(wallet_ids)]), Status.OK return jsonify([amilk._asdict() for amilk in get_amilks(wallet_ids)]), HTTPStatus.OK
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"]) @amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
def api_amilkit(amilk_id): async def api_amilkit(amilk_id):
milk = get_amilk(amilk_id) milk = get_amilk(amilk_id)
memo = milk.id memo = milk.id
try: try:
withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse) withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse)
except LnurlException: except LnurlException:
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.") abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
print(withdraw_res.max_sats)
payment_hash, payment_request = create_invoice(
try: wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo, extra={"tag": "amilk"}
checking_id, payment_request = create_invoice(wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo) )
#print(payment_request)
except Exception as e:
error_message = False, str(e)
r = requests.get( r = requests.get(
withdraw_res.callback.base, withdraw_res.callback.base,
@@ -50,45 +44,45 @@ def api_amilkit(amilk_id):
) )
if not r.ok: if not r.ok:
abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
for i in range(10): for i in range(10):
invoice_status = WALLET.get_invoice_status(checking_id)
sleep(i) sleep(i)
if not invoice_status.paid: invoice_status = check_invoice_status(milk.wallet, payment_hash)
continue if invoice_status.paid:
return jsonify({"paid": True}), HTTPStatus.OK
else: else:
return jsonify({"paid": False}), Status.OK continue
break
return jsonify({"paid": True}), Status.OK return jsonify({"paid": False}), HTTPStatus.OK
@amilk_ext.route("/api/v1/amilk", methods=["POST"]) @amilk_ext.route("/api/v1/amilk", methods=["POST"])
@api_check_wallet_key("invoice") @api_check_wallet_key("invoice")
@api_validate_post_request(schema={ @api_validate_post_request(
schema={
"lnurl": {"type": "string", "empty": False, "required": True}, "lnurl": {"type": "string", "empty": False, "required": True},
"atime": {"type": "integer", "min": 0, "required": True}, "atime": {"type": "integer", "min": 0, "required": True},
"amount": {"type": "integer", "min": 0, "required": True}, "amount": {"type": "integer", "min": 0, "required": True},
}) }
def api_amilk_create(): )
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"]) amilk = create_amilk(wallet_id=g.wallet.id, lnurl=g.data["lnurl"], atime=g.data["atime"], amount=g.data["amount"])
return jsonify(amilk._asdict()), Status.CREATED return jsonify(amilk._asdict()), HTTPStatus.CREATED
@amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"]) @amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"])
@api_check_wallet_key("invoice") @api_check_wallet_key("invoice")
def api_amilk_delete(amilk_id): async def api_amilk_delete(amilk_id):
amilk = get_amilk(amilk_id) amilk = get_amilk(amilk_id)
if not amilk: if not amilk:
return jsonify({"message": "Paywall does not exist."}), Status.NOT_FOUND return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
if amilk.wallet != g.wallet.id: if amilk.wallet != g.wallet.id:
return jsonify({"message": "Not your amilk."}), Status.FORBIDDEN return jsonify({"message": "Not your amilk."}), HTTPStatus.FORBIDDEN
delete_amilk(amilk_id) delete_amilk(amilk_id)
return "", Status.NO_CONTENT return "", HTTPStatus.NO_CONTENT
+1 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint from quart import Blueprint
diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates") diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates")
@@ -2,5 +2,5 @@
"name": "Diagon Alley", "name": "Diagon Alley",
"short_description": "Movable anonymous market stand", "short_description": "Movable anonymous market stand",
"icon": "add_shopping_cart", "icon": "add_shopping_cart",
"contributors": ["eillarra"] "contributors": ["benarc"]
} }
+89 -21
View File
@@ -6,19 +6,25 @@ from lnbits.db import open_ext_db
from lnbits.settings import WALLET from lnbits.settings import WALLET
from .models import Products, Orders, Indexers from .models import Products, Orders, Indexers
import re import re
regex = re.compile( regex = re.compile(
r'^(?:http|ftp)s?://' # http:// or https:// 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"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|"
r'localhost|' r"localhost|"
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
r'(?::\d+)?' r"(?::\d+)?"
r'(?:/?|[/?]\S+)$', re.IGNORECASE) r"(?:/?|[/?]\S+)$",
re.IGNORECASE,
)
###Products ###Products
def create_diagonalleys_product(*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int) -> Products:
def create_diagonalleys_product(
*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int
) -> Products:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
product_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8') product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute( db.execute(
""" """
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity) INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
@@ -63,19 +69,39 @@ def delete_diagonalleys_product(product_id: str) -> None:
db.execute("DELETE FROM products WHERE id = ?", (product_id,)) db.execute("DELETE FROM products WHERE id = ?", (product_id,))
###Indexers ###Indexers
def create_diagonalleys_indexer(wallet_id: str, shopname: str, indexeraddress: str, shippingzone1: str, shippingzone2: str, zone1cost: int, zone2cost: int, email: str) -> Indexers:
def create_diagonalleys_indexer(
wallet_id: str,
shopname: str,
indexeraddress: str,
shippingzone1: str,
shippingzone2: str,
zone1cost: int,
zone2cost: int,
email: str,
) -> Indexers:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8') indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute( db.execute(
""" """
INSERT INTO indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email) INSERT INTO indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
(indexer_id, wallet_id, shopname, indexeraddress, False, 0, shippingzone1, shippingzone2, zone1cost, zone2cost, email), (
indexer_id,
wallet_id,
shopname,
indexeraddress,
False,
0,
shippingzone1,
shippingzone2,
zone1cost,
zone2cost,
email,
),
) )
return get_diagonalleys_indexer(indexer_id) return get_diagonalleys_indexer(indexer_id)
@@ -99,10 +125,22 @@ def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
print(x) print(x)
print("poo") print("poo")
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (True, indexer_id,)) db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
True,
indexer_id,
),
)
else: else:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (False, indexer_id,)) db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
False,
indexer_id,
),
)
except: except:
print("An exception occurred") print("An exception occurred")
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
@@ -123,10 +161,22 @@ def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexer
x = requests.get(r["indexeraddress"] + "/" + r["ratingkey"]) x = requests.get(r["indexeraddress"] + "/" + r["ratingkey"])
if x.status_code == 200: if x.status_code == 200:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (True, r["id"],)) db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
True,
r["id"],
),
)
else: else:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (False, r["id"],)) db.execute(
"UPDATE indexers SET online = ? WHERE id = ?",
(
False,
r["id"],
),
)
except: except:
print("An exception occurred") print("An exception occurred")
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
@@ -140,12 +190,24 @@ def delete_diagonalleys_indexer(indexer_id: str) -> None:
db.execute("DELETE FROM indexers WHERE id = ?", (indexer_id,)) db.execute("DELETE FROM indexers WHERE id = ?", (indexer_id,))
###Orders ###Orders
def create_diagonalleys_order(*, productid: str, wallet: str, product: str, quantity: int, shippingzone: str, address: str, email: str, invoiceid: str, paid: bool, shipped: bool) -> Indexers:
def create_diagonalleys_order(
*,
productid: str,
wallet: str,
product: str,
quantity: int,
shippingzone: str,
address: str,
email: str,
invoiceid: str,
paid: bool,
shipped: bool,
) -> Indexers:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
order_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8') order_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
db.execute( db.execute(
""" """
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped) INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
@@ -175,7 +237,13 @@ def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
PAID = WALLET.get_invoice_status(r["invoiceid"]).paid PAID = WALLET.get_invoice_status(r["invoiceid"]).paid
if PAID: if PAID:
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("UPDATE orders SET paid = ? WHERE id = ?", (True, r["id"],)) db.execute(
"UPDATE orders SET paid = ? WHERE id = ?",
(
True,
r["id"],
),
)
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)) rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
return [Orders(**row) for row in rows] return [Orders(**row) for row in rows]
+12 -16
View File
@@ -1,11 +1,9 @@
from lnbits.db import open_ext_db
def m001_initial(db): def m001_initial(db):
""" """
Initial products table. Initial products table.
""" """
db.execute(""" db.execute(
"""
CREATE TABLE IF NOT EXISTS products ( CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
@@ -16,13 +14,14 @@ def m001_initial(db):
price INTEGER NOT NULL, price INTEGER NOT NULL,
quantity INTEGER NOT NULL quantity INTEGER NOT NULL
); );
""") """
)
""" """
Initial indexers table. Initial indexers table.
""" """
db.execute(""" db.execute(
"""
CREATE TABLE IF NOT EXISTS indexers ( CREATE TABLE IF NOT EXISTS indexers (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
@@ -36,13 +35,14 @@ def m001_initial(db):
zone2cost INTEGER NOT NULL, zone2cost INTEGER NOT NULL,
email TEXT NOT NULL email TEXT NOT NULL
); );
""") """
)
""" """
Initial orders table. Initial orders table.
""" """
db.execute(""" db.execute(
"""
CREATE TABLE IF NOT EXISTS orders ( CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
productid TEXT NOT NULL, productid TEXT NOT NULL,
@@ -56,9 +56,5 @@ def m001_initial(db):
paid BOOLEAN NOT NULL, paid BOOLEAN NOT NULL,
shipped BOOLEAN NOT NULL shipped BOOLEAN NOT NULL
); );
""") """
)
def migrate():
with open_ext_db("diagonalley") as db:
m001_initial(db)
+3 -2
View File
@@ -1,5 +1,6 @@
from typing import NamedTuple from typing import NamedTuple
class Indexers(NamedTuple): class Indexers(NamedTuple):
id: str id: str
wallet: str wallet: str
@@ -13,6 +14,7 @@ class Indexers(NamedTuple):
zone2cost: int zone2cost: int
email: str email: str
class Products(NamedTuple): class Products(NamedTuple):
id: str id: str
wallet: str wallet: str
@@ -23,6 +25,7 @@ class Products(NamedTuple):
price: int price: int
quantity: int quantity: int
class Orders(NamedTuple): class Orders(NamedTuple):
id: str id: str
productid: str productid: str
@@ -35,5 +38,3 @@ class Orders(NamedTuple):
invoiceid: str invoiceid: str
paid: bool paid: bool
shipped: bool shipped: bool
@@ -1,19 +1,29 @@
<q-expansion-item
<q-expansion-item
group="extras" group="extras"
icon="swap_vertical_circle" icon="swap_vertical_circle"
label="Info" label="Info"
:content-inset-level="0.5" :content-inset-level="0.5"
> >
<q-card> <q-card>
<q-card-section> <q-card-section>
<h5 class="text-subtitle1 q-my-none">Diagon Alley: Movable, anonymous market stall</h5> <h5 class="text-subtitle1 q-my-none">
<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/> Diagon Alley: Decentralised Market-Stalls
<small> Created by, <a href="https://github.com/benarc">Ben Arc</a></small></p> </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-card>
</q-card-section>
</q-card-section>
</q-expansion-item> </q-expansion-item>
<q-expansion-item <q-expansion-item
group="extras" group="extras"
@@ -21,43 +31,91 @@
label="API info" label="API info"
:content-inset-level="0.5" :content-inset-level="0.5"
> >
<q-expansion-item
<q-expansion-item group="api" dense expand-separator label="Get prodcuts, categorised by wallet"> group="api"
dense
expand-separator
label="Get prodcuts, categorised by wallet"
>
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-light-blue">GET</span> /api/v1/diagonalley/stall/products/&lt;indexer_id&gt;</code> <code
><span class="text-light-blue">GET</span>
/api/v1/diagonalley/stall/products/&lt;indexer_id&gt;</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">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>Product JSON list</code> <code>Product JSON list</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <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/&lt;indexer_id&gt;</code> <code
>curl -X GET {{ request.url_root
}}diagonalley/api/v1/diagonalley/stall/products/&lt;indexer_id&gt;</code
>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get invoice for product"> <q-expansion-item
group="api"
dense
expand-separator
label="Get invoice for product"
>
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/diagonalley/stall/order/&lt;indexer_id&gt;</code> <code
><span class="text-light-green">POST</span>
/api/v1/diagonalley/stall/order/&lt;indexer_id&gt;</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">Body (application/json)</h5>
<code>{"id": &lt;string&gt;, "address": &lt;string&gt;, "shippingzone": &lt;integer&gt;, "email": &lt;string&gt;, "quantity": &lt;integer&gt;}</code> <code
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5> >{"id": &lt;string&gt;, "address": &lt;string&gt;, "shippingzone":
<code>{"checking_id": &lt;string&gt;,"payment_request": &lt;string&gt;}</code> &lt;integer&gt;, "email": &lt;string&gt;, "quantity":
&lt;integer&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"checking_id": &lt;string&gt;,"payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <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/&lt;indexer_id&gt; -d '{"id": &lt;product_id&&gt;, "email": &lt;customer_email&gt;, "address": &lt;customer_address&gt;, "quantity": 2, "shippingzone": 1}' -H "Content-type: application/json" <code
>curl -X POST {{ request.url_root
}}diagonalley/api/v1/diagonalley/stall/order/&lt;indexer_id&gt; -d
'{"id": &lt;product_id&&gt;, "email": &lt;customer_email&gt;,
"address": &lt;customer_address&gt;, "quantity": 2, "shippingzone":
1}' -H "Content-type: application/json"
</code> </code>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Check a product has been shipped" <q-expansion-item
class="q-mb-md"> group="api"
dense
expand-separator
label="Check a product has been shipped"
class="q-mb-md"
>
<q-card> <q-card>
<q-card-section> <q-card-section>
<code><span class="text-light-blue">GET</span> /diagonalley/api/v1/diagonalley/stall/checkshipped/&lt;checking_id&gt;</code> <code
><span class="text-light-blue">GET</span>
/diagonalley/api/v1/diagonalley/stall/checkshipped/&lt;checking_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5> <h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK (application/json)</h5> <h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"shipped": &lt;boolean&gt;}</code> <code>{"shipped": &lt;boolean&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5> <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/&lt;checking_id&gt; -H "Content-type: application/json"</code> <code
>curl -X GET {{ request.url_root
}}diagonalley/api/v1/diagonalley/stall/checkshipped/&lt;checking_id&gt;
-H "Content-type: application/json"</code
>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-expansion-item> </q-expansion-item>
File diff suppressed because it is too large Load Diff
@@ -1 +1,3 @@
<script>console.log("{{ stall }}")</script> <script>
console.log('{{ stall }}')
</script>
+4 -7
View File
@@ -1,14 +1,11 @@
from flask import g, abort, render_template, jsonify from quart import g, render_template
import json
from lnbits.decorators import check_user_exists, validate_uuids from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.diagonalley import diagonalley_ext from lnbits.extensions.diagonalley import diagonalley_ext
from lnbits.helpers import Status
from lnbits.db import open_ext_db
@diagonalley_ext.route("/") @diagonalley_ext.route("/")
@validate_uuids(["usr"], required=True) @validate_uuids(["usr"], required=True)
@check_user_exists() @check_user_exists()
def index(): async def index():
return await render_template("diagonalley/index.html", user=g.user)
return render_template("diagonalley/index.html", user=g.user)
+115 -64
View File
@@ -1,232 +1,272 @@
from flask import g, jsonify, request from quart import g, jsonify, request
from http import HTTPStatus
from lnbits.core.crud import get_user from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.helpers import Status
from lnbits.extensions.diagonalley import diagonalley_ext from lnbits.extensions.diagonalley import diagonalley_ext
from .crud import create_diagonalleys_product,get_diagonalleys_product,get_diagonalleys_products,delete_diagonalleys_product,create_diagonalleys_indexer,update_diagonalleys_indexer,get_diagonalleys_indexer,get_diagonalleys_indexers,delete_diagonalleys_indexer,create_diagonalleys_order,get_diagonalleys_order,get_diagonalleys_orders,delete_diagonalleys_order from .crud import (
create_diagonalleys_product,
get_diagonalleys_product,
get_diagonalleys_products,
delete_diagonalleys_product,
create_diagonalleys_indexer,
update_diagonalleys_indexer,
get_diagonalleys_indexer,
get_diagonalleys_indexers,
delete_diagonalleys_indexer,
create_diagonalleys_order,
get_diagonalleys_order,
get_diagonalleys_orders,
update_diagonalleys_product,
)
from lnbits.core.services import create_invoice from lnbits.core.services import create_invoice
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from uuid import uuid4 from uuid import uuid4
from lnbits.db import open_ext_db from lnbits.db import open_ext_db
###Products ### Products
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["GET"]) @diagonalley_ext.route("/api/v1/diagonalley/products", methods=["GET"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
def api_diagonalley_products(): async def api_diagonalley_products():
wallet_ids = [g.wallet.id] wallet_ids = [g.wallet.id]
if "all_wallets" in request.args: if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([product._asdict() for product in get_diagonalleys_products(wallet_ids)]), Status.OK return jsonify([product._asdict() for product in get_diagonalleys_products(wallet_ids)]), HTTPStatus.OK
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"]) @diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"])
@diagonalley_ext.route("/api/v1/diagonalley/products<product_id>", methods=["PUT"]) @diagonalley_ext.route("/api/v1/diagonalley/products<product_id>", methods=["PUT"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
@api_validate_post_request(schema={ @api_validate_post_request(
schema={
"product": {"type": "string", "empty": False, "required": True}, "product": {"type": "string", "empty": False, "required": True},
"categories": {"type": "string", "empty": False, "required": True}, "categories": {"type": "string", "empty": False, "required": True},
"description": {"type": "string", "empty": False, "required": True}, "description": {"type": "string", "empty": False, "required": True},
"image": {"type": "string", "empty": False, "required": True}, "image": {"type": "string", "empty": False, "required": True},
"price": {"type": "integer", "min": 0, "required": True}, "price": {"type": "integer", "min": 0, "required": True},
"quantity": {"type": "integer", "min": 0, "required": True} "quantity": {"type": "integer", "min": 0, "required": True},
}) }
def api_diagonalley_product_create(product_id=None): )
async def api_diagonalley_product_create(product_id=None):
if product_id: if product_id:
product = get_diagonalleys_indexer(product_id) product = get_diagonalleys_indexer(product_id)
if not product: if not product:
return jsonify({"message": "Withdraw product does not exist."}), Status.NOT_FOUND return jsonify({"message": "Withdraw product does not exist."}), HTTPStatus.NOT_FOUND
if product.wallet != g.wallet.id: if product.wallet != g.wallet.id:
return jsonify({"message": "Not your withdraw product."}), Status.FORBIDDEN return jsonify({"message": "Not your withdraw product."}), HTTPStatus.FORBIDDEN
product = update_diagonalleys_product(product_id, **g.data) product = update_diagonalleys_product(product_id, **g.data)
else: else:
product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data) product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data)
return jsonify(product._asdict()), Status.OK if product_id else Status.CREATED return jsonify(product._asdict()), HTTPStatus.OK if product_id else HTTPStatus.CREATED
@diagonalley_ext.route("/api/v1/diagonalley/products/<product_id>", methods=["DELETE"]) @diagonalley_ext.route("/api/v1/diagonalley/products/<product_id>", methods=["DELETE"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
def api_diagonalley_products_delete(product_id): async def api_diagonalley_products_delete(product_id):
product = get_diagonalleys_product(product_id) product = get_diagonalleys_product(product_id)
if not product: if not product:
return jsonify({"message": "Product does not exist."}), Status.NOT_FOUND return jsonify({"message": "Product does not exist."}), HTTPStatus.NOT_FOUND
if product.wallet != g.wallet.id: if product.wallet != g.wallet.id:
return jsonify({"message": "Not your Diagon Alley."}), Status.FORBIDDEN return jsonify({"message": "Not your Diagon Alley."}), HTTPStatus.FORBIDDEN
delete_diagonalleys_product(product_id) delete_diagonalleys_product(product_id)
return "", Status.NO_CONTENT return "", HTTPStatus.NO_CONTENT
###Indexers ###Indexers
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["GET"]) @diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["GET"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
def api_diagonalley_indexers(): async def api_diagonalley_indexers():
wallet_ids = [g.wallet.id] wallet_ids = [g.wallet.id]
if "all_wallets" in request.args: if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]), Status.OK return jsonify([indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]), HTTPStatus.OK
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"]) @diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"])
@diagonalley_ext.route("/api/v1/diagonalley/indexers<indexer_id>", methods=["PUT"]) @diagonalley_ext.route("/api/v1/diagonalley/indexers<indexer_id>", methods=["PUT"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
@api_validate_post_request(schema={ @api_validate_post_request(
schema={
"shopname": {"type": "string", "empty": False, "required": True}, "shopname": {"type": "string", "empty": False, "required": True},
"indexeraddress": {"type": "string", "empty": False, "required": True}, "indexeraddress": {"type": "string", "empty": False, "required": True},
"shippingzone1": {"type": "string", "empty": False, "required": True}, "shippingzone1": {"type": "string", "empty": False, "required": True},
"shippingzone2": {"type": "string", "empty": False, "required": True}, "shippingzone2": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True}, "email": {"type": "string", "empty": False, "required": True},
"zone1cost": {"type": "integer", "min": 0, "required": True}, "zone1cost": {"type": "integer", "min": 0, "required": True},
"zone2cost": {"type": "integer", "min": 0, "required": True} "zone2cost": {"type": "integer", "min": 0, "required": True},
}) }
def api_diagonalley_indexer_create(indexer_id=None): )
async def api_diagonalley_indexer_create(indexer_id=None):
if indexer_id: if indexer_id:
indexer = get_diagonalleys_indexer(indexer_id) indexer = get_diagonalleys_indexer(indexer_id)
if not indexer: if not indexer:
return jsonify({"message": "Withdraw indexer does not exist."}), Status.NOT_FOUND return jsonify({"message": "Withdraw indexer does not exist."}), HTTPStatus.NOT_FOUND
if indexer.wallet != g.wallet.id: if indexer.wallet != g.wallet.id:
return jsonify({"message": "Not your withdraw indexer."}), Status.FORBIDDEN return jsonify({"message": "Not your withdraw indexer."}), HTTPStatus.FORBIDDEN
indexer = update_diagonalleys_indexer(indexer_id, **g.data) indexer = update_diagonalleys_indexer(indexer_id, **g.data)
else: else:
indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data) indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data)
return jsonify(indexer._asdict()), Status.OK if indexer_id else Status.CREATED return jsonify(indexer._asdict()), HTTPStatus.OK if indexer_id else HTTPStatus.CREATED
@diagonalley_ext.route("/api/v1/diagonalley/indexers/<indexer_id>", methods=["DELETE"]) @diagonalley_ext.route("/api/v1/diagonalley/indexers/<indexer_id>", methods=["DELETE"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
def api_diagonalley_indexer_delete(indexer_id): async def api_diagonalley_indexer_delete(indexer_id):
indexer = get_diagonalleys_indexer(indexer_id) indexer = get_diagonalleys_indexer(indexer_id)
if not indexer: if not indexer:
return jsonify({"message": "Indexer does not exist."}), Status.NOT_FOUND return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
if indexer.wallet != g.wallet.id: if indexer.wallet != g.wallet.id:
return jsonify({"message": "Not your Indexer."}), Status.FORBIDDEN return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
delete_diagonalleys_indexer(indexer_id) delete_diagonalleys_indexer(indexer_id)
return "", Status.NO_CONTENT return "", HTTPStatus.NO_CONTENT
###Orders ###Orders
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"]) @diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
def api_diagonalley_orders(): async def api_diagonalley_orders():
wallet_ids = [g.wallet.id] wallet_ids = [g.wallet.id]
if "all_wallets" in request.args: if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]), Status.OK return jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]), HTTPStatus.OK
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"]) @diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
@api_validate_post_request(schema={ @api_validate_post_request(
schema={
"id": {"type": "string", "empty": False, "required": True}, "id": {"type": "string", "empty": False, "required": True},
"address": {"type": "string", "empty": False, "required": True}, "address": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True}, "email": {"type": "string", "empty": False, "required": True},
"quantity": {"type": "integer", "empty": False, "required": True}, "quantity": {"type": "integer", "empty": False, "required": True},
"shippingzone": {"type": "integer", "empty": False, "required": True}, "shippingzone": {"type": "integer", "empty": False, "required": True},
}) }
def api_diagonalley_order_create(): )
async def api_diagonalley_order_create():
order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data) order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data)
return jsonify(order._asdict()), Status.CREATED return jsonify(order._asdict()), HTTPStatus.CREATED
@diagonalley_ext.route("/api/v1/diagonalley/orders/<order_id>", methods=["DELETE"]) @diagonalley_ext.route("/api/v1/diagonalley/orders/<order_id>", methods=["DELETE"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
def api_diagonalley_order_delete(order_id): async def api_diagonalley_order_delete(order_id):
order = get_diagonalleys_order(order_id) order = get_diagonalleys_order(order_id)
if not order: if not order:
return jsonify({"message": "Indexer does not exist."}), Status.NOT_FOUND return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
if order.wallet != g.wallet.id: if order.wallet != g.wallet.id:
return jsonify({"message": "Not your Indexer."}), Status.FORBIDDEN return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
delete_diagonalleys_indexer(order_id) delete_diagonalleys_indexer(order_id)
return "", Status.NO_CONTENT return "", HTTPStatus.NO_CONTENT
@diagonalley_ext.route("/api/v1/diagonalley/orders/paid/<order_id>", methods=["GET"]) @diagonalley_ext.route("/api/v1/diagonalley/orders/paid/<order_id>", methods=["GET"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
def api_diagonalleys_order_paid(order_id): async def api_diagonalleys_order_paid(order_id):
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("UPDATE orders SET paid = ? WHERE id = ?", (True, order_id,)) db.execute(
return "", Status.OK "UPDATE orders SET paid = ? WHERE id = ?",
(
True,
order_id,
),
)
return "", HTTPStatus.OK
@diagonalley_ext.route("/api/v1/diagonalley/orders/shipped/<order_id>", methods=["GET"]) @diagonalley_ext.route("/api/v1/diagonalley/orders/shipped/<order_id>", methods=["GET"])
@api_check_wallet_key(key_type="invoice") @api_check_wallet_key(key_type="invoice")
def api_diagonalleys_order_shipped(order_id): async def api_diagonalleys_order_shipped(order_id):
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
db.execute("UPDATE orders SET shipped = ? WHERE id = ?", (True, order_id,)) db.execute(
"UPDATE orders SET shipped = ? WHERE id = ?",
(
True,
order_id,
),
)
order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,)) order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
return jsonify([order._asdict() for order in get_diagonalleys_orders(order["wallet"])]), Status.OK return jsonify([order._asdict() for order in get_diagonalleys_orders(order["wallet"])]), HTTPStatus.OK
###List products based on indexer id ###List products based on indexer id
@diagonalley_ext.route("/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"]) @diagonalley_ext.route("/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"])
def api_diagonalleys_stall_products(indexer_id): async def api_diagonalleys_stall_products(indexer_id):
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,)) rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
print(rows[1]) print(rows[1])
if not rows: if not rows:
return jsonify({"message": "Indexer does not exist."}), Status.NOT_FOUND return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
products = db.fetchone("SELECT * FROM products WHERE wallet = ?", (rows[1],)) products = db.fetchone("SELECT * FROM products WHERE wallet = ?", (rows[1],))
if not products: if not products:
return jsonify({"message": "No products"}), Status.NOT_FOUND return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND
return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), HTTPStatus.OK
return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), Status.OK
###Check a product has been shipped ###Check a product has been shipped
@diagonalley_ext.route("/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"]) @diagonalley_ext.route("/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"])
def api_diagonalleys_stall_checkshipped(checking_id): async def api_diagonalleys_stall_checkshipped(checking_id):
with open_ext_db("diagonalley") as db: with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,)) rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,))
return jsonify({"shipped": rows["shipped"]}), Status.OK return jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK
###Place order ###Place order
@diagonalley_ext.route("/api/v1/diagonalley/stall/order/<indexer_id>", methods=["POST"]) @diagonalley_ext.route("/api/v1/diagonalley/stall/order/<indexer_id>", methods=["POST"])
@api_validate_post_request(schema={ @api_validate_post_request(
schema={
"id": {"type": "string", "empty": False, "required": True}, "id": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True}, "email": {"type": "string", "empty": False, "required": True},
"address": {"type": "string", "empty": False, "required": True}, "address": {"type": "string", "empty": False, "required": True},
"quantity": {"type": "integer", "empty": False, "required": True}, "quantity": {"type": "integer", "empty": False, "required": True},
"shippingzone": {"type": "integer", "empty": False, "required": True}, "shippingzone": {"type": "integer", "empty": False, "required": True},
}) }
def api_diagonalley_stall_order(indexer_id): )
async def api_diagonalley_stall_order(indexer_id):
product = get_diagonalleys_product(g.data["id"]) product = get_diagonalleys_product(g.data["id"])
shipping = get_diagonalleys_indexer(indexer_id) shipping = get_diagonalleys_indexer(indexer_id)
@@ -235,17 +275,28 @@ def api_diagonalley_stall_order(indexer_id):
else: else:
shippingcost = shipping.zone2cost shippingcost = shipping.zone2cost
checking_id, payment_request = create_invoice(wallet_id=product.wallet, amount=shippingcost + (g.data["quantity"] * product.price), memo=g.data["id"]) checking_id, payment_request = create_invoice(
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8') 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: with open_ext_db("diagonalley") as db:
db.execute( db.execute(
""" """
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped) INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
(selling_id ,g.data["id"] , product.wallet, product.product, g.data["quantity"], g.data["shippingzone"], g.data["address"], g.data["email"], checking_id, False, False), (
selling_id,
g.data["id"],
product.wallet,
product.product,
g.data["quantity"],
g.data["shippingzone"],
g.data["address"],
g.data["email"],
checking_id,
False,
False,
),
) )
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), Status.OK return jsonify({"checking_id": checking_id, "payment_request": payment_request}), HTTPStatus.OK
+3 -8
View File
@@ -1,8 +1,3 @@
<h1> LNEVENTS</h1> <h1>Events</h1>
<h2>Create/sell tickets for an event</h2> <h2>Events: Sell and register event tickets</h2>
<p>Events is an easy way to create/sell tickets for an event. 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.
It is advised to setup a specific wallet in lnbits for the event.</p>
<img src="https://i.imgur.com/qHi7ExL.png">
+1 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint from quart import Blueprint
events_ext: Blueprint = Blueprint("events", __name__, static_folder="static", template_folder="templates") events_ext: Blueprint = Blueprint("events", __name__, static_folder="static", template_folder="templates")
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Events",
"short_description": "Sell and register event tickets",
"icon": "local_activity",
"contributors": ["benarc"]
}
@@ -1,6 +0,0 @@
{
"name": "Events",
"short_description": "LN tickets for events.",
"icon": "local_activity",
"contributors": ["arcbtc"]
}
-6
View File
@@ -1,6 +0,0 @@
{
"name": "Events",
"short_description": "LN tickets for events.",
"icon": "local_activity",
"contributors": ["arcbtc"]
}
+167
View File
@@ -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]
+83 -3
View File
@@ -1,5 +1,85 @@
from lnbits.db import open_ext_db 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 migrate(): def m002_changed(db):
print("pending")
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")
+26
View File
@@ -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
-26
View File
@@ -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> <p>{% raw %}{{amountWords}}{% endraw %}</p>
<html> <div class="row q-mt-lg">
<head> <q-btn
<meta charset="UTF-8" /> unelevated
<title>LNBits Wallet</title> color="deep-purple"
<meta :disable="formDialog.data.name == '' || formDialog.data.email == '' || paymentReq"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" type="submit"
name="viewport" >Submit</q-btn
/>
<!-- 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> <q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card-section>
</q-card>
<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>
<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> </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> </div>
</nav> <div class="row q-mt-lg">
</header> <q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
<aside class="main-sidebar"> >
<!-- sidebar: style can be found in sidebar.less --> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
<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><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> </div>
<button onclick="submitforticket()" type="button" class="btn btn-info">Go to payment</button><p style="color:red;" id="error"></p> </q-card>
</form> </q-dialog>
</div> </div>
</center>
<center><br/><br/> <div id="qrcode" style="width: 340px;"></div><br/><br/> {% endblock %} {% block scripts %}
<div style="width:55%;word-wrap: break-word;" id="qrcodetxt"></div> <br/></center> <script>
console.log('{{ form_costpword }}')
Vue.component(VueQrcode.name, VueQrcode)
</section><!-- /.content --> new Vue({
</div><!-- /.content-wrapper --> el: '#vue',
</div> mixins: [windowMixin],
</body> 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
}
}
},
<script> methods: {
resetForm: function (e) {
e.preventDefault()
this.formDialog.data.name = ''
this.formDialog.data.email = ''
},
closeReceiveDialog: function () {
var checker = this.receive.paymentChecker
dismissMsg()
function postAjax(url, data, thekey, success) { clearInterval(paymentChecker)
var params = setTimeout(function () {}, 10000)
typeof data == 'string' },
? data Invoice: function () {
: Object.keys(data) var self = this
.map(function(k) { axios
return encodeURIComponent(k) + '=' + encodeURIComponent(data[k])
.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...'
}) })
.join('&')
var xhr = window.XMLHttpRequest self.receive = {
? new XMLHttpRequest() show: true,
: new ActiveXObject('Microsoft.XMLHTTP') status: 'pending',
xhr.open('POST', url) paymentReq: self.paymentReq
xhr.onreadystatechange = function() {
if (xhr.readyState > 3 && xhr.status == 200) {
success(xhr.responseText)
} }
}
xhr.setRequestHeader('X-Api-Key', thekey)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send(params)
return xhr
}
function getAjax(url, thekey, success) { paymentChecker = setInterval(function () {
var xhr = window.XMLHttpRequest axios
? new XMLHttpRequest() .get('/events/api/v1/tickets/' + self.paymentCheck)
: new ActiveXObject('Microsoft.XMLHTTP') .then(function (res) {
xhr.open('GET', url, true) if (res.data.paid) {
xhr.onreadystatechange = function() { clearInterval(paymentChecker)
if (xhr.readyState > 3 && xhr.status == 200) { dismissMsg()
success(xhr.responseText) self.formDialog.data.name = ''
} self.formDialog.data.email = ''
}
xhr.setRequestHeader('X-Api-Key', thekey)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send() self.$q.notify({
return xhr type: 'positive',
} message: 'Sent, thank you!',
icon: null
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 = "" self.receive = {
show: false,
document.getElementById("qrcode").style.backgroundColor = "white"; status: 'complete',
document.getElementById("qrcode").style.padding = "20px"; paymentReq: null
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)
} }
})}, 3000);
self.ticketLink = {
}) show: true,
} data: {
link: '/events/ticket/' + res.data.ticket_id
}
}
</script> setTimeout(function () {
</html> 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 "legacy.html" %} {% block messages %} <q-card>
<q-card-section>
<a href="#" class="dropdown-toggle" data-toggle="dropdown"> <div class="row items-center no-wrap q-mb-md">
<i class="fa fa-bell-o"></i> <div class="col">
<span class="label label-danger">!</span> <h5 class="text-subtitle1 q-my-none">Events</h5>
</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('core.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('core.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>
<div class="col-auto">
<div class="form-group"> <q-btn flat color="grey" @click="exporteventsCSV"
<label>Description of event</label> >Export to CSV</q-btn
<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>
</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>
<!-- /.box-body -->
</div>
<!-- /.box -->
</div> </div>
</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 --> <q-td v-for="col in props.cols" :key="col.name" :props="props">
</section> {{ 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 <q-dialog v-model="formDialog.show" position="top">
$('#datepicker').datepicker({ <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
autoclose: true <q-form @submit="sendEventData" class="q-gutter-md">
}) <q-select
filled
dense
emit-value
window.user = {{ user | tojson | safe }} v-model="formDialog.data.wallet"
window.user_wallets = {{ user_wallets | tojson | safe }} :options="g.user.walletOptions"
window.user_ext = {{ user_ext | tojson | safe }} label="Wallet *"
window.user_ev = {{ user_ev | tojson | safe }} >
</q-select>
const user_ev = window.user_ev <q-input
console.log(user_ev) filled
dense
v-model.trim="formDialog.data.name"
function drawChart(user_ev) { type="name"
var transactionsHTML = '' label="Name of event "
></q-input>
for (var i = 0; i < user_ev.length; i++) { <q-input
var ev = user_ev[i] filled
dense
transactionsHTML = v-model.trim="formDialog.data.info"
"<tr><td style='width: 50%'>" + type="textarea"
ev.tit + label="Info about the event "
'</td><td>' + ></q-input>
ev.notickets + <q-input
'</td><td>' + filled
ev.sold + dense
'</td><td>' + v-model.trim="formDialog.data.closing_date"
ev.cldate + type="date"
'</td><td>' + label="Closing for tickets "
ev.prtick + ></q-input>
'</td><td>' + <q-input
"<a href='{{ url_for('wallet') }}?usr="+ ev.usr +"'>" + ev.wal.substring(0, 4) + "...</a>" + filled
'</td><td>' + dense
"<i onclick='editlink("+ i +")'' class='fa fa-edit'></i>" + v-model.trim="formDialog.data.event_start_date"
'</td><td>' + type="date"
"<b><a style='color:red;' href='" + "{{ url_for('events.index') }}?del=" + ev.uni + "&usr=" + ev.usr +"'>" + "<i class='fa fa-trash'></i>" + "</a></b>" + label="Event begins "
'</td></tr>' + ></q-input>
transactionsHTML <q-input
document.getElementById('ticketwaves').innerHTML = transactionsHTML filled
dense
} v-model.trim="formDialog.data.event_end_date"
} type="date"
label="Event ends "
if (user_ev.length) { ></q-input>
drawChart(user_ev) <q-input
} filled
dense
function postev(){ v-model.number="formDialog.data.amount_tickets"
type="number"
wal = document.getElementById('wal').value label="Amount of tickets "
tit = document.getElementById('tit').value ></q-input>
cldate = document.getElementById('datepicker').value <q-input
notickets = document.getElementById('notickets').value filled
prtickets = document.getElementById('prtickets').value dense
descr = document.getElementById('descr').value v-model.number="formDialog.data.price_per_ticket"
type="number"
if (tit == "") { label="Price per ticket "
document.getElementById("error").innerHTML = "Only use letters in title" ></q-input>
return amt <div class="row q-mt-lg">
} <q-btn
if (wal == "") { v-if="formDialog.data.id"
document.getElementById("error").innerHTML = "No wallet selected" unelevated
return amt color="deep-purple"
} type="submit"
if (cldate == "") { >Update Event</q-btn
document.getElementById("error").innerHTML = "No date selected" >
return amt <q-btn
} v-else
unelevated
if (isNaN(notickets) || notickets < 1) { color="deep-purple"
document.getElementById("error").innerHTML = "Must be more than 1" :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"
return amt type="submit"
} >Create Event</q-btn
if (isNaN(prtickets) || prtickets < 10) { >
document.getElementById("error").innerHTML = "Must be higher 10" <q-btn v-close-popup flat color="grey" class="q-ml-auto"
return amt >Cancel</q-btn
} >
</div>
postAjax( </q-form>
"{{ url_for('events.create') }}", </q-card>
JSON.stringify({"tit": tit, "usr": user, "wal": wal, "notickets": notickets,"cldate": cldate, "prtickets": prtickets, "descr": descr}), </q-dialog>
"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>
</div> </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 %} {% 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 | tojson | safe }}
window.user_ev_sold = {{ user_ev_sold | tojson | 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('X-Api-Key', 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('X-Api-Key', 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> <qrcode
<html> :value="'{{ ticket_id }}'"
<head> :options="{width: 340}"
<meta charset="UTF-8" /> class="rounded-borders"
<title>LNBits Wallet</title> ></qrcode>
<meta <br />
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" <q-btn @click="printWindow" color="grey" class="q-ml-auto">
name="viewport" <q-icon left size="3em" name="print"></q-icon> Print</q-btn
/>
<!-- 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> </center>
</a> </q-card-section>
<div class="navbar-custom-menu"> </q-card>
<ul class="nav navbar-nav">
<!-- Messages: style can be found in dropdown.less-->
<li class="dropdown messages-menu">
{% block messages %}{% endblock %}
</li>
</ul>
</div> </div>
</nav> </div>
</header> {% endblock %} {% block scripts %}
<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>
<script> <script>
new QRCode(document.getElementById('qrcode'), { Vue.component(VueQrcode.name, VueQrcode)
text: "{{ticket}}", new Vue({
width: 300, el: '#vue',
height: 300, mixins: [windowMixin],
colorDark: '#000000', data: function () {
colorLight: '#ffffff', return {}
correctLevel: QRCode.CorrectLevel.M },
methods: {
printWindow: function () {
window.print()
}
}
}) })
</script>
{% endblock %}
</script>
</html>
+37 -156
View File
@@ -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 lnbits.decorators import check_user_exists, validate_uuids
from datetime import datetime from http import HTTPStatus
from lnbits.db import open_db, open_ext_db
from lnbits.extensions.events import events_ext from lnbits.extensions.events import events_ext
from .crud import get_ticket, get_event
@events_ext.route("/") @events_ext.route("/")
def index(): @validate_uuids(["usr"], required=True)
"""Main events link page.""" @check_user_exists()
usr = request.args.get("usr") 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 @events_ext.route("/<event_id>")
with open_db() as db: async def display(event_id):
user_wallets = db.fetchall("SELECT * FROM wallets WHERE user = ?", (usr,)) event = get_event(event_id) or abort(HTTPStatus.NOT_FOUND, "Event does not exist.")
user_ext = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (usr,)) if event.amount_tickets < 1:
user_ext = [v[0] for v in user_ext] return await render_template(
"events/error.html", event_name=event.name, event_error="Sorry, tickets are sold out :("
with open_ext_db("events") as events_ext_db: )
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE usr = ?", (usr,)) datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date()
if date.today() > datetime_object:
# If del is selected by user from events page, the event link is to be deleted return await render_template(
evdel = request.args.get("del") "events/error.html", event_name=event.name, event_error="Sorry, ticket closing date has passed :("
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"]) return await render_template(
def create(): "events/display.html",
""".""" event_id=event_id,
event_name=event.name,
data = request.json event_info=event.info,
tit = data["tit"] event_price=event.price_per_ticket,
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,
),
)
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
) )
@events_ext.route("/ticket/<ticket_id>")
@events_ext.route("/wave/<wave>/", methods=["GET", "POST"]) async def ticket(ticket_id):
def wave(wave): 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(
with open_ext_db("events") as events_ext_db: "events/ticket.html", ticket_id=ticket_id, ticket_name=event.name, ticket_info=event.info
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("/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_ext.route("/register/<event_id>")
"events/registration.html", user_ev=user_ev, user_ev_sold=user_ev_sold 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"]) return await render_template(
def ticket(): "events/register.html", event_id=event_id, event_name=event.name, wallet_id=event.wallet
"""."""
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
) )
+173 -52
View File
@@ -1,65 +1,186 @@
import uuid from quart import g, jsonify, request
import json from http import HTTPStatus
import requests
from flask import jsonify, request, url_for from lnbits.core.crud import get_user, get_wallet
from datetime import datetime 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 from lnbits.extensions.events import events_ext
from .crud import (
@events_ext.route("/api/v1/getticket/", methods=["GET","POST"]) create_ticket,
def api_getticket(): update_ticket,
""".""" get_ticket,
get_tickets,
data = request.json delete_ticket,
unireg = data["unireg"] create_event,
name = data["name"] update_event,
email = request.args.get("ema") get_event,
get_events,
with open_ext_db("events") as events_ext_db: delete_event,
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE unireg = ?", (unireg,)) get_event_tickets,
reg_ticket,
)
header = {"Content-Type": "application/json", "X-Api-Key": user_ev[0][4]} #########Events##########
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()
if "ERROR" in r_json:
return jsonify({"status": "ERROR", "reason": r_json["ERROR"]}), 400
events_ext_db.execute( @events_ext.route("/api/v1/events", methods=["GET"])
""" @api_check_wallet_key("invoice")
INSERT OR IGNORE INTO eventssold async def api_events():
(uni, email, name, hash) wallet_ids = [g.wallet.id]
VALUES (?, ?, ?, ?)
""", if "all_wallets" in request.args:
( wallet_ids = get_user(g.wallet.user).wallet_ids
unireg,
email, return jsonify([event._asdict() for event in get_events(wallet_ids)]), HTTPStatus.OK
name,
r_json["payment_hash"]
), @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
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/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
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({"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,
) )
return jsonify({"status": "TRUE", "pay_req": r_json["pay_req"], "payment_hash": r_json["payment_hash"]}), 200
@events_ext.route("/api/v1/register/ticket/<ticket_id>", methods=["GET"])
async def api_event_register_ticket(ticket_id):
@events_ext.route("/api/v1/checkticket/", methods=["GET"]) ticket = get_ticket(ticket_id)
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
with open_ext_db("events") as events_ext_db: if not ticket:
events_ext_db.execute("UPDATE eventssold SET reg = 1 WHERE hash = ?", (thehash,)) return jsonify({"message": "Ticket does not exist."}), HTTPStatus.FORBIDDEN
return jsonify({"status": "TRUE", "name": eventssold[0][3]}), 200 if ticket.registered == True:
return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN
return jsonify([ticket._asdict() for ticket in reg_ticket(ticket_id)]), HTTPStatus.OK
+1 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint from quart import Blueprint
example_ext: Blueprint = Blueprint("example", __name__, static_folder="static", template_folder="templates") example_ext: Blueprint = Blueprint("example", __name__, static_folder="static", template_folder="templates")
-5
View File
@@ -1,5 +0,0 @@
from lnbits.db import open_ext_db
def migrate():
print("pending")
@@ -1,15 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
{% from "macros.jinja" import window_vars with context %} <q-card>
{% block page %}
<q-card>
<q-card-section> <q-card-section>
<h5 class="text-subtitle1 q-mt-none q-mb-md">Frameworks used by LNbits</h5> <h5 class="text-subtitle1 q-mt-none q-mb-md">Frameworks used by LNbits</h5>
<q-list> <q-list>
<q-item v-for="tool in tools" :key="tool.name" tag="a" :href="tool.url" target="_blank"> <q-item
{% raw %} <!-- with raw Flask won't try to interpret the Vue moustaches --> v-for="tool in tools"
:key="tool.name"
tag="a"
:href="tool.url"
target="_blank"
>
{% raw %}
<!-- with raw Flask won't try to interpret the Vue moustaches -->
<q-item-section> <q-item-section>
<q-item-label>{{ tool.name }}</q-item-label> <q-item-label>{{ tool.name }}</q-item-label>
<q-item-label caption>{{ tool.language }}</q-item-label> <q-item-label caption>{{ tool.language }}</q-item-label>
@@ -18,25 +21,25 @@
</q-item> </q-item>
</q-list> </q-list>
<q-separator class="q-my-lg"></q-separator> <q-separator class="q-my-lg"></q-separator>
<p>A magical "g" is always available, with info about the user, wallets and extensions:</p> <p>
A magical "g" is always available, with info about the user, wallets and
extensions:
</p>
<code class="text-caption">{% raw %}{{ g }}{% endraw %}</code> <code class="text-caption">{% raw %}{{ g }}{% endraw %}</code>
</q-card-section> </q-card-section>
</q-card> </q-card>
{% endblock %} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
{% block scripts %}
{{ window_vars(user) }}
<script>
new Vue({ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
tools: [] tools: []
}; }
}, },
created: function () { created: function () {
var self = this; var self = this
// axios is available for making requests // axios is available for making requests
axios({ axios({
@@ -46,9 +49,9 @@
'X-example-header': 'not-used' 'X-example-header': 'not-used'
} }
}).then(function (response) { }).then(function (response) {
self.tools = response.data; self.tools = response.data
}); })
} }
}); })
</script> </script>
{% endblock %} {% endblock %}
+3 -3
View File
@@ -1,4 +1,4 @@
from flask import g, render_template from quart import g, render_template
from lnbits.decorators import check_user_exists, validate_uuids from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.example import example_ext from lnbits.extensions.example import example_ext
@@ -7,5 +7,5 @@ from lnbits.extensions.example import example_ext
@example_ext.route("/") @example_ext.route("/")
@validate_uuids(["usr"], required=True) @validate_uuids(["usr"], required=True)
@check_user_exists() @check_user_exists()
def index(): async def index():
return render_template("example/index.html", user=g.user) return await render_template("example/index.html", user=g.user)
+6 -5
View File
@@ -5,16 +5,17 @@
# import json # import json
# import requests # import requests
from flask import jsonify from quart import jsonify
from http import HTTPStatus
from lnbits.helpers import Status
from lnbits.extensions.example import example_ext from lnbits.extensions.example import example_ext
# add your endpoints here # add your endpoints here
@example_ext.route("/api/v1/tools", methods=["GET"]) @example_ext.route("/api/v1/tools", methods=["GET"])
def api_example(): async def api_example():
"""Try to add descriptions for others.""" """Try to add descriptions for others."""
tools = [ tools = [
{ {
@@ -31,7 +32,7 @@ def api_example():
"name": "Quasar Framework", "name": "Quasar Framework",
"url": "https://quasar.dev/", "url": "https://quasar.dev/",
"language": "JavaScript", "language": "JavaScript",
} },
] ]
return jsonify(tools), Status.OK return jsonify(tools), HTTPStatus.OK
+6
View File
@@ -0,0 +1,6 @@
<h1>lndhub Extension</h1>
<h2>*connect to your lnbits wallet from BlueWallet or Zeus*</h2>
Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/.
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club.
+8
View File
@@ -0,0 +1,8 @@
from quart import Blueprint
lndhub_ext: Blueprint = Blueprint("lndhub", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
from .views import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "LndHub",
"short_description": "Access lnbits from BlueWallet or Zeus.",
"icon": "navigation",
"contributors": ["fiatjaf"]
}
+25
View File
@@ -0,0 +1,25 @@
from base64 import b64decode
from quart import jsonify, g, request
from functools import wraps
from lnbits.core.crud import get_wallet_for_key
def check_wallet(requires_admin=False):
def wrap(view):
@wraps(view)
async def wrapped_view(**kwargs):
token = request.headers["Authorization"].split("Bearer ")[1]
key_type, key = b64decode(token).decode("utf-8").split(":")
if requires_admin and key_type != "admin":
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
g.wallet = get_wallet_for_key(key, key_type)
if not g.wallet:
return jsonify({"error": True, "code": 2, "message": "insufficient permissions"})
return await view(**kwargs)
return wrapped_view
return wrap
+2
View File
@@ -0,0 +1,2 @@
def migrate():
pass
@@ -0,0 +1,35 @@
<q-expansion-item
group="extras"
icon="info"
label="Instructions"
default-opened
>
<q-card>
<q-card-section>
To access an LNbits wallet from a mobile phone,
<ol>
<li>
Install either <a href="https://zeusln.app">Zeus</a> or
<a href="https://bluewallet.io/">BlueWallet</a>;
</li>
<li>
Go to <code>Add a wallet / Import wallet</code> on BlueWallet or
<code>Settings / Add a new node</code> on Zeus.
</li>
<li>Select the desired wallet on this page;</li>
<li>Scan one of the two QR codes from the mobile wallet.</li>
</ol>
<ul>
<li>
<em>Invoice</em> URLs mean the mobile wallet will only have the
authorization to read your payments and invoices and generate new
invoices.
</li>
<li>
<em>Admin</em> URLs mean the mobile wallet will be able to pay
invoices..
</li>
</ul>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -0,0 +1,19 @@
<q-expansion-item group="extras" icon="info" label="LndHub info">
<q-card>
<q-card-section>
<p>
LndHub is a protocol invented by
<a href="https://bluewallet.io/">BlueWallet</a> that allows mobile
wallets to query payments and balances, generate invoices and make
payments from accounts that exist on a server. The protocol is a
collection of HTTP endpoints exposed through the internet.
</p>
<p>
For a wallet that supports it, reading a QR code that contains the URL
along with secret access credentials should enable access. Currently it
is supported by <a href="https://zeusln.app">Zeus</a> and
<a href="https://bluewallet.io/">BlueWallet</a>.
</p>
</q-card-section>
</q-card>
</q-expansion-item>
@@ -0,0 +1,92 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %} {% raw %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<div class="row q-col-gutter-md">
<q-card
v-for="type in ['invoice', 'admin']"
v-bind:key="type"
class="q-pa-sm q-ma-sm col-5"
>
<q-card-section class="q-pa-none">
<div class="text-center">
<a :href="selectedWallet[type]">
<q-responsive :ratio="1" class="q-mx-sm">
<qrcode
:value="selectedWallet[type]"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg items-center justify-center">
<q-btn
outline
color="grey"
@click="copyText(selectedWallet[type])"
class="text-center q-mb-md"
>Copy LndHub {{type}} URL</q-btn
>
</div>
</q-card-section>
</q-card>
</div>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form class="q-gutter-md">
<q-select
filled
dense
v-model="selectedWallet"
:options="wallets"
label="Select wallet:"
>
</q-select>
</q-form>
</q-card>
</div>
{% endraw %}
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits LndHub extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "lndhub/_instructions.html" %}
<q-separator></q-separator>
{% include "lndhub/_lndhub.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
var wallets = JSON.parse('{{ g.user.wallets | tojson }}')
.map(LNbits.map.wallet)
.map(wallet => ({
label: wallet.name,
admin: `lndhub://admin:${wallet.adminkey}@${location.protocol}//${location.host}/lndhub/ext/`,
invoice: `lndhub://invoice:${wallet.inkey}@${location.protocol}//${location.host}/lndhub/ext/`
}))
return {
wallets: wallets,
selectedWallet: wallets[0]
}
}
})
</script>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
from binascii import unhexlify
from lnbits.bolt11 import Invoice
def to_buffer(payment_hash: str):
return {"type": "Buffer", "data": [b for b in unhexlify(payment_hash)]}
def decoded_as_lndhub(invoice: Invoice):
return {
"destination": invoice.payee,
"payment_hash": invoice.payment_hash,
"num_satoshis": invoice.amount_msat / 1000,
"timestamp": str(invoice.date),
"expiry": str(invoice.expiry),
"description": invoice.description,
"fallback_addr": "",
"cltv_expiry": invoice.min_final_cltv_expiry,
"route_hints": "",
}
+11
View File
@@ -0,0 +1,11 @@
from quart import render_template, g
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.lndhub import lndhub_ext
@lndhub_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
async def lndhub_index():
return await render_template("lndhub/index.html", user=g.user)

Some files were not shown because too many files have changed in this diff Show More