Compare commits

...
170 Commits
Author SHA1 Message Date
Eneko IllarramendiandGitHub 0197c6c44a Merge pull request #23 from sgeisler/mypy_fixes
Fix mypy errors
2020-04-27 14:39:34 +02:00
Eneko IllarramendiandSebastian Geisler c3e337a319 fix: mypy errors 2020-04-26 16:58:29 +02:00
ArcandGitHub 976a3d4e5c Update lndrest.py 2020-04-26 01:10:57 +01:00
Eneko Illarramendi 4b4a297c3f refactor(paywall): remove unnecessary hashing paranoia 2020-04-25 23:55:07 +02:00
ArcandGitHub 5d128523c7 Update lndrest.py 2020-04-25 22:41:27 +01:00
ArcandGitHub b34a0a9835 Update .env.example 2020-04-25 22:39:30 +01:00
ArcandGitHub 99cfdd5531 Update __init__.py 2020-04-25 22:11:27 +01:00
ArcandGitHub 435f9c9773 Add files via upload 2020-04-25 22:10:45 +01:00
Eneko Illarramendi ab190454f4 fix: LNbits backend wallet 2020-04-25 20:13:49 +02:00
Eneko IllarramendiandGitHub 5fe64d324d Merge pull request #16 from sgeisler/2020-04-ci
Add type checking CI
2020-04-25 09:28:38 +02:00
Sebastian Geisler 19fa6c2bf9 Add type checking CI 2020-04-25 00:24:59 +02:00
Eneko Illarramendi efd2cb7170 fix: use a fee reserve and actual wallet fees 2020-04-24 22:45:09 +02:00
Eneko Illarramendi 4995ab5aaf chore: linguist config 2020-04-24 15:17:05 +02:00
ArcandEneko Illarramendi 7acbe9f93c Update README.md 2020-04-24 14:56:10 +02:00
ArcandEneko Illarramendi 122281ba81 Update README.md 2020-04-24 14:56:10 +02:00
Eneko Illarramendi 97f4fabea1 fix: create temporal payments to keep balance updated 2020-04-24 14:56:10 +02:00
ArcandGitHub 2c30209cba Update README.md 2020-04-24 09:42:40 +01:00
ArcandGitHub 9f22c0395e Update README.md 2020-04-23 22:43:30 +01:00
ArcandGitHub 2cf5b13b93 Update README.md 2020-04-23 22:42:50 +01:00
ArcandGitHub bedf01b1b6 Update README.md 2020-04-23 22:29:07 +01:00
ArcandGitHub eff41ddf8d Update README.md 2020-04-23 22:27:45 +01:00
ArcandGitHub 19e439c9a2 Update README.md 2020-04-23 21:51:55 +01:00
ArcandGitHub fb86aad6a9 Update README.md 2020-04-23 21:47:48 +01:00
ArcandGitHub e226df2894 Update README.md 2020-04-23 21:46:00 +01:00
ArcandGitHub d15ba01f94 Update README.md 2020-04-23 21:40:51 +01:00
Eneko Illarramendi 897644956a feat: only dhow disclaimer if SERVICE_FEE has been set 2020-04-23 21:25:55 +02:00
Eneko Illarramendi f02526585f docs: add LNbits logos to repo 2020-04-23 18:42:27 +02:00
Eneko Illarramendi 4bf93d36c6 refactor: change the WITH_ONION env var and use FORCE_HTTPS instead 2020-04-23 18:37:22 +02:00
Eneko Illarramendi e461e34fc0 feat(vue): move confirmDialog to LNbits.utils so we can reuse the styling 2020-04-23 07:32:45 +02:00
Eneko Illarramendi 34e6d2a7bb chore: update requirements 2020-04-23 07:32:05 +02:00
Eneko Illarramendi 1978cafe44 chore: update supporter logos 2020-04-23 07:23:45 +02:00
ArcandGitHub cd22aaded9 Update index.html 2020-04-22 20:55:51 +01:00
Eneko Illarramendi 498eceeb46 feat: use dynamic root in api docs 2020-04-22 21:49:31 +02:00
ArcandGitHub 39041ffb5a Update _api_docs.html 2020-04-22 20:41:18 +01:00
ArcandGitHub 0c88aa8fcf Update views_api.py 2020-04-22 19:47:30 +01:00
Eneko Illarramendi eb938c11a8 chore(paywall): minor updates 2020-04-21 23:47:21 +02:00
Eneko Illarramendi b1da103080 chore: minor tweaks 2020-04-21 23:47:21 +02:00
Eneko Illarramendi d90ae26062 style: icon change 2020-04-21 23:43:15 +02:00
Eneko Illarramendi 403385c205 feat: paywall extension 2020-04-21 23:42:41 +02:00
Eneko Illarramendi fd4dc6c48f chore: remove pending macaroon mentions 2020-04-21 23:22:01 +02:00
Eneko Illarramendi e1c5e970c4 feat: allow disabling extensions at installation level 2020-04-21 23:17:11 +02:00
Eneko Illarramendi f12f52db2d fix: homepage bug 2020-04-21 23:17:11 +02:00
Eneko Illarramendi 8d328c5465 refactor: remove legacy code 2020-04-21 23:17:11 +02:00
Eneko Illarramendi efb9deb028 feat: remove FEE_RESERVE 2020-04-21 23:17:11 +02:00
Eneko Illarramendi 5806beaeb7 feat(core): move disclaimer to wallet page 2020-04-21 23:15:50 +02:00
Eneko Illarramendi d917feeb02 feat: prepare for deploying to Dokku 2020-04-21 23:13:37 +02:00
Eneko Illarramendi efbba20872 style: minor tweaks 2020-04-21 23:13:37 +02:00
Eneko Illarramendi 93e51a7398 fix: wallet button 2020-04-21 23:13:37 +02:00
Eneko Illarramendi 94e524bf4c chore: clean requirements 2020-04-21 08:17:55 +02:00
Eneko Illarramendi 364b6dc857 fix(vue): use @submit to send forms, and catch users pressing return 2020-04-21 08:16:56 +02:00
ArcandGitHub bb2d1ea140 Update .env.example 2020-04-20 20:35:59 +01:00
ArcandGitHub 409122271a Update index.html 2020-04-20 15:29:01 +01:00
ArcandGitHub 07b4d52432 Update index.html 2020-04-20 15:27:46 +01:00
ArcandGitHub ae778c19a1 Update index.js 2020-04-20 15:27:02 +01:00
ArcandGitHub 65c17488e3 Update index.html 2020-04-20 14:19:38 +01:00
ArcandGitHub 98f7dc8eff Add files via upload 2020-04-20 14:00:38 +01:00
ArcandGitHub 6bdf4e22e4 Delete quick.gif 2020-04-20 13:56:18 +01:00
ArcandGitHub 549976b9d0 Delete stamps.jpg 2020-04-20 13:56:06 +01:00
ArcandGitHub f0cbaea0b1 Delete where39.png 2020-04-20 13:55:54 +01:00
ArcandGitHub ca08dc2302 Update index.html 2020-04-20 13:48:46 +01:00
ArcandGitHub 7a1477c056 Update _api_docs.html 2020-04-20 08:37:27 +01:00
ArcandGitHub 27b02e85db Update _tpos.html 2020-04-20 08:36:27 +01:00
ArcandGitHub d82e06a4f9 Update views_api.py 2020-04-20 08:29:19 +01:00
ArcandGitHub 39098b2432 Update index.html 2020-04-20 08:28:12 +01:00
ArcandGitHub 5aab51b78c Update index.html 2020-04-20 08:27:08 +01:00
ArcandGitHub 5f9c9c9df6 Create _tpos.html 2020-04-20 08:26:21 +01:00
ArcandGitHub 40fc7ad08f Create _api_docs.html 2020-04-20 08:25:45 +01:00
ArcandGitHub 51587d7de2 Update views_api.py 2020-04-20 08:00:22 +01:00
Eneko Illarramendi 1154d632a2 chore: update currencies list from OpenNode 2020-04-19 23:36:57 +02:00
ArcandGitHub 1862cccb2a Update index.js 2020-04-19 20:56:54 +01:00
ArcandGitHub c33a45d20e Update _lnurl.html 2020-04-19 20:54:39 +01:00
ArcandGitHub 0178097ee6 Added admin to some of the calls 2020-04-19 20:54:11 +01:00
Eneko Illarramendi 57c26ed2bb chore: outline some buttons 2020-04-18 23:17:16 +02:00
Eneko Illarramendi 9e98dae317 chore: update js dependencies 2020-04-18 09:18:56 +02:00
ArcandGitHub aa13551bd3 Update config.json 2020-04-18 01:39:10 +01:00
ArcandGitHub b52618f6f5 Update config.json 2020-04-18 01:38:25 +01:00
ArcandGitHub 5c34c23617 Update crud.py 2020-04-18 01:37:31 +01:00
ArcandGitHub 5b16bd0224 Update migrations.py 2020-04-18 01:37:10 +01:00
ArcandGitHub 316b2256e4 Update models.py 2020-04-18 01:36:50 +01:00
ArcandGitHub 10b8f90fcc Update views_api.py 2020-04-18 01:35:59 +01:00
ArcandGitHub b38e6bf566 Update index.html 2020-04-18 01:35:14 +01:00
Eneko Illarramendi d4c9043278 feat(core): delete expired payments 2020-04-17 21:13:57 +02:00
Eneko Illarramendi d03e472cc2 feat(core): add search to payments table 2020-04-17 21:13:32 +02:00
Eneko Illarramendi de75057a15 chore: remove legacy frontend libraries 2020-04-17 20:43:14 +02:00
Eneko Illarramendi a455379ee1 refactor(tpos): migrate extension 2020-04-17 20:39:23 +02:00
ArcandGitHub 2d694c6b8a Update index.html 2020-04-17 18:52:07 +01:00
ArcandGitHub 3dadf5b54f Update crud.py 2020-04-17 18:51:24 +01:00
ArcandGitHub e330771b44 Update migrations.py 2020-04-17 18:51:06 +01:00
ArcandGitHub 03a365256f Update models.py 2020-04-17 18:50:47 +01:00
ArcandGitHub 83bb184ff3 Update views_api.py 2020-04-17 18:48:17 +01:00
ArcandGitHub 49c1537b25 Update index.html 2020-04-16 23:26:31 +01:00
ArcandGitHub c9ff6c9a4c Update and rename config.json.example to config.json 2020-04-16 23:03:12 +01:00
ArcandGitHub 68fbe2dabf Rename config.json to config.json.example 2020-04-16 22:12:02 +01:00
ArcandGitHub d5f4697251 Added Diagon Alley extension 2020-04-16 21:51:17 +01:00
Eneko Illarramendi fd4dddda6e chore: rename api-key-macaroon 2020-04-16 20:58:16 +02:00
Eneko Illarramendi a834a64319 refactor(withdraw): migrate extension to Vue 2020-04-16 20:58:16 +02:00
Eneko Illarramendi dd23b20090 chore: various tweaks 2020-04-16 20:58:16 +02:00
Eneko Illarramendi 490e166f75 refactor: improve database migrations 2020-04-16 20:58:16 +02:00
ArcandGitHub 6651122bb6 Update _api_docs.html 2020-04-16 12:04:17 +01:00
ArcandGitHub 764fde012c Update decorators.py 2020-04-16 12:03:00 +01:00
ArcandGitHub abc694307a Update base.js 2020-04-16 12:02:20 +01:00
ArcandGitHub 016d07bc4d Add files via upload 2020-04-14 11:39:54 +01:00
ArcandGitHub 2f9542bee6 Update index.html 2020-04-14 04:07:47 +01:00
ArcandGitHub a637a7f7e7 Update tpos.html 2020-04-14 04:06:19 +01:00
ArcandGitHub aff2ab1780 Update index.html 2020-04-14 03:50:01 +01:00
ArcandGitHub 609f5e8470 Update index.html 2020-04-13 22:33:37 +01:00
ArcandGitHub ba9449db23 Update views.py 2020-04-13 22:31:54 +01:00
ArcandGitHub 1b82df8094 Update tpos.html 2020-04-13 22:31:30 +01:00
ArcandGitHub 708b2f2652 Update lndgrpc.py 2020-04-13 22:23:42 +01:00
ArcandGitHub 7a6c87bd8e Update tpos.html 2020-04-13 21:38:15 +01:00
ArcandGitHub a468904356 Update views.py 2020-04-13 21:36:14 +01:00
ArcandGitHub 11c8e46b26 Update views_api.py 2020-04-13 21:35:35 +01:00
Eneko Illarramendi af5269e239 refactor: add core.utils for creating and paying invoices
This methods can be used by extensions too.
2020-04-11 23:15:24 +02:00
Eneko Illarramendi 4397a6cab3 feat: use cerberus schemas to validate POST data 2020-04-11 20:16:54 +02:00
ArcandGitHub 949f816a78 Update index.html 2020-04-10 22:06:56 +01:00
ArcandGitHub f40ead9701 Update tpos.html 2020-04-10 22:06:36 +01:00
ArcandGitHub ce46e8eb31 Create crud.py 2020-04-10 22:05:50 +01:00
ArcandGitHub 77d8851009 Create models.py 2020-04-10 22:05:31 +01:00
ArcandGitHub d282e6dea6 Update schema.sql 2020-04-10 22:05:11 +01:00
ArcandGitHub 211b72648b Update views.py 2020-04-10 22:04:48 +01:00
ArcandGitHub e65c0a47bb Update views_api.py 2020-04-10 22:04:29 +01:00
Eneko Illarramendi 768f0427e7 feat(paywall): extension basics 2020-04-05 12:20:03 +02:00
Eneko Illarramendi fb7dfb3a32 chore: minor fixes 2020-04-05 12:19:25 +02:00
ArcandGitHub 089793a486 Update wallet.js 2020-04-04 23:38:52 +01:00
ArcandGitHub c866d0572e Added top margin to modals 2020-04-04 22:53:28 +01:00
ArcandGitHub 7b962ffc4b Update views_api.py 2020-04-04 00:35:39 +01:00
Eneko Illarramendi 8dc2143080 chore: update requirements 2020-04-03 22:52:08 +02:00
Eneko Illarramendi a81de95f73 chore(vue): use g for consistency with Flask 2020-04-03 22:38:50 +02:00
Eneko Illarramendi d0b44eada9 chore: brokenbits example extension 2020-04-03 22:28:25 +02:00
ArcandGitHub 434029966f Update index.html 2020-04-03 20:36:44 +01:00
ArcandGitHub d6cab311c5 Update views.py 2020-04-03 20:00:16 +01:00
ArcandGitHub 676bd24d38 Update views.py 2020-04-03 19:59:40 +01:00
ArcandGitHub 5fb17a9244 Update index.html 2020-04-03 18:52:26 +01:00
ArcandGitHub de187559a8 Update lnbits.py 2020-04-03 13:50:54 +01:00
ArcandGitHub 7fced84f57 Update .env.example 2020-04-03 13:48:24 +01:00
ArcandGitHub 1e6c6a8c3c Update __init__.py 2020-04-03 13:46:40 +01:00
ArcandGitHub 426981fe1c Create lnbits.py 2020-04-03 13:46:14 +01:00
ArcandGitHub 46c11f3946 Update wallet.html 2020-04-03 13:16:23 +01:00
ArcandGitHub 8fe9864e43 Update wallet.html
Changed value to amount, added a curl example
2020-04-03 12:57:36 +01:00
ArcandGitHub 8db35ca1af Update lndgrpc.py 2020-04-03 12:27:07 +01:00
ArcandGitHub 77e849f056 Update clightning.py 2020-04-03 12:12:21 +01:00
ArcandGitHub 377b0c9d47 Update clightning.py 2020-04-03 11:48:41 +01:00
ArcandGitHub 0ca5586465 Update .env.example 2020-04-03 11:47:59 +01:00
ArcandGitHub 1248f85612 Update clightning.py 2020-04-03 11:27:55 +01:00
ArcandGitHub e57bebd01b Update clightning.py 2020-04-03 11:27:36 +01:00
ArcandGitHub 908d1cbf24 Update .env.example 2020-04-03 11:24:49 +01:00
ArcandGitHub 97719623a3 Update __init__.py 2020-04-03 11:22:05 +01:00
ArcandGitHub ccb9b462cc Create clightning.py 2020-04-03 11:21:10 +01:00
ArcandGitHub 127f1ee258 Update lndgrpc.py 2020-04-02 15:03:32 +01:00
ArcandGitHub 1db380d88e Update lndgrpc.py 2020-04-02 15:01:38 +01:00
ArcandGitHub 9ead85987f Update __init__.py 2020-04-02 13:07:01 +01:00
ArcandGitHub 4562d23f6e Delete lnd.py 2020-04-02 13:06:15 +01:00
ArcandGitHub f6f2304c5a Update requirements.txt 2020-04-02 12:50:13 +01:00
ArcandGitHub d194ba5fe1 Update lndgrpc.py 2020-04-02 12:43:48 +01:00
ArcandGitHub c2a116b713 Update lndgrpc.py 2020-04-02 12:43:23 +01:00
ArcandGitHub c6f47fc589 Update .env.example 2020-04-02 12:42:36 +01:00
ArcandGitHub 4533363c49 Update .env.example
Added lnd grpc vars, uses the Zap example
2020-04-02 12:12:03 +01:00
ArcandGitHub efc847d381 Update lndgrpc.py 2020-04-02 12:03:10 +01:00
ArcandGitHub b02bee01e5 Update lndgrpc.py 2020-04-02 07:45:00 +01:00
ArcandGitHub f364ce51c0 Update __init__.py 2020-04-02 07:44:35 +01:00
ArcandGitHub 73ec8deee1 Create lndgrpc.py
Doesn't work, but hopefully will soon
2020-04-02 07:44:03 +01:00
Eneko Illarramendi 649cc888ab refactor: /wallet tweaks 2020-04-01 22:18:46 +02:00
Eneko Illarramendi d03785558b refactor: a wallet is a wallet is a wallet 2020-03-31 19:05:25 +02:00
Eneko Illarramendi 75d97ddfc1 refactor: camera ready 2020-03-10 23:12:22 +01:00
Eneko Illarramendi 49addb8d56 refactor: breaking bad 2020-03-08 23:00:41 +01:00
Eneko Illarramendi d862b16ee6 refactor: "payments" is the name, and API updates 2020-03-07 22:27:00 +01:00
ArcandGitHub 6e26e06aea Added python-dotenv so flask would pick up .env 2020-03-05 23:27:13 +00:00
Eneko Illarramendi 7996c48b70 chore: remove wait option for LntxbotWallet 2020-03-05 23:01:37 +01:00
Eneko Illarramendi 9382381144 chore: initial version of tx chart 2020-03-05 23:01:37 +01:00
Eneko Illarramendi f98a5040ac refactor: decorators, models and more broken bits 2020-03-04 23:11:15 +01:00
389 changed files with 58236 additions and 80227 deletions
-2
View File
@@ -5,8 +5,6 @@ charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{html,js,json}]
indent_size = 2
indent_style = space
+34 -12
View File
@@ -1,10 +1,40 @@
FLASK_APP=lnbits
FLASK_ENV=development
LND_API_ENDPOINT=https://mylnd.io/rest/
LND_ADMIN_MACAROON=LND_ADMIN_MACAROON
LND_INVOICE_MACAROON=LND_INVOICE_MACAROON
LND_READ_MACAROON=LND_READ_MACAROON
LNBITS_SITE_TITLE=LNbits
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
LNBITS_DATA_FOLDER="/your_custom_data_folder"
LNBITS_DISABLED_EXTENSIONS="amilk,events"
LNBITS_FORCE_HTTPS=1
LNBITS_SERVICE_FEE="0.0"
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet, CLightningWallet, LnbitsWallet
LNBITS_BACKEND_WALLET_CLASS=LntxbotWallet
CLIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc"
LNBITS_ENDPOINT=127.0.0.1:5000
LNBITS_INVOICE_MACAROON=LNBITS_INVOICE_MACAROON
LNBITS_ADMIN_MACAROON=LNBITS_ADMIN_MACAROON
LND_GRPC_ENDPOINT=127.0.0.1
LND_GRPC_PORT=11009
LND_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_ADMIN_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon"
LND_INVOICE_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/invoice.macaroon"
LND_READ_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/read.macaroon"
LND_REST_ENDPOINT=https://localhost:8080/
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
LND_REST_ADMIN_MACAROON="HEXSTRING"
LND_REST_INVOICE_MACAROON="HEXSTRING"
LND_REST_READ_MACAROON="HEXSTRING"
LNPAY_API_ENDPOINT=https://lnpay.co/v1/
LNPAY_API_KEY=LNPAY_API_KEY
LNPAY_ADMIN_KEY=LNPAY_ADMIN_KEY
LNPAY_INVOICE_KEY=LNPAY_INVOICE_KEY
LNPAY_READ_KEY=LNPAY_READ_KEY
LNTXBOT_API_ENDPOINT=https://lntxbot.bigsun.xyz/
LNTXBOT_ADMIN_KEY=LNTXBOT_ADMIN_KEY
@@ -13,11 +43,3 @@ LNTXBOT_INVOICE_KEY=LNTXBOT_INVOICE_KEY
OPENNODE_API_ENDPOINT=https://api.opennode.com/
OPENNODE_ADMIN_KEY=OPENNODE_ADMIN_KEY
OPENNODE_INVOICE_KEY=OPENNODE_INVOICE_KEY
LNPAY_API_ENDPOINT=https://lnpay.co/v1/
LNPAY_ADMIN_KEY=LNPAY_ADMIN_KEY
LNPAY_INVOICE_KEY=LNPAY_INVOICE_KEY
LNPAY_READ_KEY=LNPAY_READ_KEY
LNPAY_API_KEY=LNPAY_API_KEY
FEE_RESERVE=0
+2
View File
@@ -0,0 +1,2 @@
lnbits/static/vendor/** linguist-vendored
lnbits/extensions/**/static/vendor/** linguist-vendored
+13
View File
@@ -0,0 +1,13 @@
name: mypy type checker
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: jpetrucciani/mypy-check@master
with:
path: 'lnbits'
+4 -1
View File
@@ -11,8 +11,9 @@ __pycache__
*.egg-info
.coverage
.pytest_cache
.webassets-cache
htmlcov
Pipfile.lock
test-reports
*.swo
*.swp
@@ -24,3 +25,5 @@ venv
*.sqlite3
.pyre*
__bundle__
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.7
WORKDIR /app
COPY requirements.txt /app/
RUN pip install --no-cache-dir -q -r requirements.txt
COPY . /app
EXPOSE 5000
+9
View File
@@ -8,10 +8,19 @@ python_version = "3.7"
[packages]
bitstring = "*"
cerberus = "*"
lnd-grpc = "*"
lnurl = "*"
flask = "*"
flask-assets = "*"
flask-compress = "*"
flask-talisman = "*"
gevent = "*"
gunicorn = "*"
pylightning = "*"
pyscss = "*"
requests = "*"
shortuuid = "*"
[dev-packages]
black = "==19.10b0"
Generated
+733
View File
@@ -0,0 +1,733 @@
{
"_meta": {
"hash": {
"sha256": "1c40bedbf1f2a9e9bccd443b57bd6dda496361df8712ddda5efcec0d00cab9f1"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.7"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"bech32": {
"hashes": [
"sha256:7d6db8214603bd7871fcfa6c0826ef68b85b0abd90fa21c285a9c5e21d2bd899",
"sha256:990dc8e5a5e4feabbdf55207b5315fdd9b73db40be294a19b3752cde9e79d981"
],
"version": "==1.2.0"
},
"bitstring": {
"hashes": [
"sha256:7b60b0c300d0d3d0a24ec84abfda4b0eaed3dc56dc90f6cbfe497166c9ad8443",
"sha256:c97a8e2a136e99b523b27da420736ae5cb68f83519d633794a6a11192f69f8bf",
"sha256:e392819965e7e0246e3cf6a51d5a54e731890ae03ebbfa3cd0e4f74909072096"
],
"index": "pypi",
"version": "==3.1.6"
},
"cerberus": {
"hashes": [
"sha256:302e6694f206dd85cb63f13fd5025b31ab6d38c99c50c6d769f8fa0b0f299589"
],
"index": "pypi",
"version": "==1.3.2"
},
"certifi": {
"hashes": [
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
"sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
],
"version": "==2020.4.5.1"
},
"chardet": {
"hashes": [
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
],
"version": "==3.0.4"
},
"click": {
"hashes": [
"sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc",
"sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"
],
"version": "==7.1.1"
},
"flask": {
"hashes": [
"sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060",
"sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"
],
"index": "pypi",
"version": "==1.1.2"
},
"flask-assets": {
"hashes": [
"sha256:1dfdea35e40744d46aada72831f7613d67bf38e8b20ccaaa9e91fdc37aa3b8c2",
"sha256:2845bd3b479be9db8556801e7ebc2746ce2d9edb4e7b64a1c786ecbfc1e5867b"
],
"index": "pypi",
"version": "==2.0"
},
"flask-compress": {
"hashes": [
"sha256:468693f4ddd11ac6a41bca4eb5f94b071b763256d54136f77957cfee635badb3"
],
"index": "pypi",
"version": "==1.4.0"
},
"flask-talisman": {
"hashes": [
"sha256:468131464a249274ed226efc21b372518f442487e58918ccab8357eaa638fd1f",
"sha256:eaa754f4b771dfbe473843391d69643b79e3a38c865790011ac5e4179c68e3ec"
],
"index": "pypi",
"version": "==0.7.0"
},
"gevent": {
"hashes": [
"sha256:0b84a8d6f088b29a74402728681c9f11864b95e49f5587a666e6fbf5c683e597",
"sha256:1ef086264e846371beb5742ebaeb148dc96adf72da2ff350ae5603421cdc2ad9",
"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": "==20.4.0"
},
"googleapis-common-protos": {
"hashes": [
"sha256:013c91704279119150e44ef770086fdbba158c1f978a6402167d47d5409e226e"
],
"version": "==1.51.0"
},
"greenlet": {
"hashes": [
"sha256:000546ad01e6389e98626c1367be58efa613fa82a1be98b0c6fc24b563acc6d0",
"sha256:0d48200bc50cbf498716712129eef819b1729339e34c3ae71656964dac907c28",
"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": "==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": {
"hashes": [
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
],
"version": "==2.9"
},
"itsdangerous": {
"hashes": [
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
],
"version": "==1.1.0"
},
"jinja2": {
"hashes": [
"sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
"sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
],
"version": "==2.11.2"
},
"lnd-grpc": {
"hashes": [
"sha256:43114aead2dc2d008751231784fc94027d60569a5e4dc86ed68032a2203fe781",
"sha256:c28208d912b28f5b0b11fa87559c6e564731549ecbc52eafe7d5dd2b2f469cf8"
],
"index": "pypi",
"version": "==0.4.0"
},
"lnurl": {
"hashes": [
"sha256:633ddedddcff4bd8b7790eba1649ec10c91fb28e690e3f6e984cb8591be3b626",
"sha256:749e0d73a8e5d41b03c734df4f22c0e022f1da4f64e600a898b56e1bb75f5dda"
],
"index": "pypi",
"version": "==0.3.3"
},
"markupsafe": {
"hashes": [
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
],
"version": "==1.1.1"
},
"protobuf": {
"hashes": [
"sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab",
"sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f",
"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"
},
"pydantic": {
"hashes": [
"sha256:0b7aadfa1de28057656064e04d9f018d1b186fe2a8e953a2fb41545873b7cf95",
"sha256:0f61e67291b99a927816558a218a4e794db72a33621c836e63d12613a2202cd4",
"sha256:20946280c750753b3e3177c748825ef189d7ab86c514f6a0b118621110d5f0d3",
"sha256:22139ee446992c222977ac0a9269c4da2e9ecc1834f84804ebde008a4649b929",
"sha256:3c0f39e884d7a3572d5cc8322b0fe9bf66114283e22e05a5c4b8961c19588945",
"sha256:446ce773a552a2cb90065d4aa645e16fa7494369b5f0d199e4d41a992a98204d",
"sha256:475e6606873e40717cc3b0eebc7d1101cbfc774e01dadeeea24c121eb5826b86",
"sha256:66124752662de0479a9d0c17bdebdc8a889bccad8846626fb66d8669e8eafb63",
"sha256:896637b7d8e4cdc0bcee1704fcadacdd167c35ac29f02a4395fce7a033925f26",
"sha256:9af44d06db33896a2176603c9cb876df3a60297a292a24d3018956a910cc1402",
"sha256:9e46fac8a4674db0777fd0133aa56817e1481beee50971bab39dded7639f9b2b",
"sha256:ae206e103e976c40ec294cd6c8fcbfbdaced3ab9b736bc53d03fa11b5aaa1628",
"sha256:b11d0bd7ecf41098894e8777ee623c29554dbaa37e862c51bcc5a2b950d1bf77",
"sha256:d73070028f7b046a5b2e611a9799c238d7bd245f8fe30f4ad7ff29ddb63aac40",
"sha256:ddedcdf9d5c24939578449a8e099ceeec3b3d76243fc143aff63ebf6d5aade10",
"sha256:e08e21f4d5395ac17cde19de26be63fb16fb870f0cfde1481ddc22d5e2353548",
"sha256:e6239199b363bc53262bcb57f1441206d4b2d46b392eccba2213d8358d6e284a"
],
"version": "==1.5"
},
"pylightning": {
"hashes": [
"sha256:5b7df2c6f0faf17450ea3024f29f901a114d4c68da116a1b130958df510a0557",
"sha256:6a07b62b27e01baaf51165e2dfb8b7d1d47f61aef8304a9da3ca5081608a32c0"
],
"index": "pypi",
"version": "==0.0.7.3"
},
"pyscss": {
"hashes": [
"sha256:f1df571569021a23941a538eb154405dde80bed35dc1ea7c5f3e18e0144746bf"
],
"index": "pypi",
"version": "==1.3.7"
},
"requests": {
"hashes": [
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
"sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
],
"index": "pypi",
"version": "==2.23.0"
},
"shortuuid": {
"hashes": [
"sha256:3c11d2007b915c43bee3e10625f068d8a349e04f0d81f08f5fa08507427ebf1f",
"sha256:492c7402ff91beb1342a5898bd61ea953985bf24a41cd9f247409aa2e03c8f77"
],
"index": "pypi",
"version": "==1.0.1"
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
"version": "==1.14.0"
},
"typing-extensions": {
"hashes": [
"sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5",
"sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae",
"sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"
],
"markers": "python_version < '3.8'",
"version": "==3.7.4.2"
},
"urllib3": {
"hashes": [
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
"sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
],
"version": "==1.25.9"
},
"webassets": {
"hashes": [
"sha256:167132337677c8cedc9705090f6d48da3fb262c8e0b2773b29f3352f050181cd",
"sha256:a31a55147752ba1b3dc07dee0ad8c8efff274464e08bbdb88c1fd59ffd552724"
],
"version": "==2.0"
},
"werkzeug": {
"hashes": [
"sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43",
"sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"
],
"version": "==1.0.1"
}
},
"develop": {
"appdirs": {
"hashes": [
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
],
"version": "==1.4.3"
},
"attrs": {
"hashes": [
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
],
"version": "==19.3.0"
},
"black": {
"hashes": [
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
],
"index": "pypi",
"version": "==19.10b0"
},
"click": {
"hashes": [
"sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc",
"sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"
],
"version": "==7.1.1"
},
"coverage": {
"hashes": [
"sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a",
"sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355",
"sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65",
"sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7",
"sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9",
"sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1",
"sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0",
"sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55",
"sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c",
"sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6",
"sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef",
"sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019",
"sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e",
"sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0",
"sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf",
"sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24",
"sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2",
"sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c",
"sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4",
"sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0",
"sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd",
"sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04",
"sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e",
"sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730",
"sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2",
"sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768",
"sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796",
"sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7",
"sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a",
"sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489",
"sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052"
],
"version": "==5.1"
},
"entrypoints": {
"hashes": [
"sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19",
"sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"
],
"version": "==0.3"
},
"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": {
"hashes": [
"sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c",
"sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"
],
"version": "==8.2.0"
},
"mypy": {
"hashes": [
"sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2",
"sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1",
"sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164",
"sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761",
"sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce",
"sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27",
"sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754",
"sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae",
"sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9",
"sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600",
"sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65",
"sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8",
"sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913",
"sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3"
],
"version": "==0.770"
},
"mypy-extensions": {
"hashes": [
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
],
"version": "==0.4.3"
},
"packaging": {
"hashes": [
"sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3",
"sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"
],
"version": "==20.3"
},
"pathspec": {
"hashes": [
"sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
"sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
],
"version": "==0.8.0"
},
"pluggy": {
"hashes": [
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
],
"version": "==0.13.1"
},
"py": {
"hashes": [
"sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
"sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
],
"version": "==1.8.1"
},
"pycodestyle": {
"hashes": [
"sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56",
"sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"
],
"version": "==2.5.0"
},
"pyflakes": {
"hashes": [
"sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0",
"sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"
],
"version": "==2.1.1"
},
"pyparsing": {
"hashes": [
"sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1",
"sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"
],
"version": "==2.4.7"
},
"pytest": {
"hashes": [
"sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172",
"sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970"
],
"index": "pypi",
"version": "==5.4.1"
},
"pytest-cov": {
"hashes": [
"sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b",
"sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"
],
"index": "pypi",
"version": "==2.8.1"
},
"pytest-sugar": {
"hashes": [
"sha256:26cf8289fe10880cbbc130bd77398c4e6a8b936d8393b116a5c16121d95ab283",
"sha256:fcd87a74b2bce5386d244b49ad60549bfbc4602527797fac167da147983f58ab"
],
"index": "pypi",
"version": "==0.9.2"
},
"regex": {
"hashes": [
"sha256:08119f707f0ebf2da60d2f24c2f39ca616277bb67ef6c92b72cbf90cbe3a556b",
"sha256:0ce9537396d8f556bcfc317c65b6a0705320701e5ce511f05fc04421ba05b8a8",
"sha256:1cbe0fa0b7f673400eb29e9ef41d4f53638f65f9a2143854de6b1ce2899185c3",
"sha256:2294f8b70e058a2553cd009df003a20802ef75b3c629506be20687df0908177e",
"sha256:23069d9c07e115537f37270d1d5faea3e0bdded8279081c4d4d607a2ad393683",
"sha256:24f4f4062eb16c5bbfff6a22312e8eab92c2c99c51a02e39b4eae54ce8255cd1",
"sha256:295badf61a51add2d428a46b8580309c520d8b26e769868b922750cf3ce67142",
"sha256:2a3bf8b48f8e37c3a40bb3f854bf0121c194e69a650b209628d951190b862de3",
"sha256:4385f12aa289d79419fede43f979e372f527892ac44a541b5446617e4406c468",
"sha256:5635cd1ed0a12b4c42cce18a8d2fb53ff13ff537f09de5fd791e97de27b6400e",
"sha256:5bfed051dbff32fd8945eccca70f5e22b55e4148d2a8a45141a3b053d6455ae3",
"sha256:7e1037073b1b7053ee74c3c6c0ada80f3501ec29d5f46e42669378eae6d4405a",
"sha256:90742c6ff121a9c5b261b9b215cb476eea97df98ea82037ec8ac95d1be7a034f",
"sha256:a58dd45cb865be0ce1d5ecc4cfc85cd8c6867bea66733623e54bd95131f473b6",
"sha256:c087bff162158536387c53647411db09b6ee3f9603c334c90943e97b1052a156",
"sha256:c162a21e0da33eb3d31a3ac17a51db5e634fc347f650d271f0305d96601dc15b",
"sha256:c9423a150d3a4fc0f3f2aae897a59919acd293f4cb397429b120a5fcd96ea3db",
"sha256:ccccdd84912875e34c5ad2d06e1989d890d43af6c2242c6fcfa51556997af6cd",
"sha256:e91ba11da11cf770f389e47c3f5c30473e6d85e06d7fd9dcba0017d2867aab4a",
"sha256:ea4adf02d23b437684cd388d557bf76e3afa72f7fed5bbc013482cc00c816948",
"sha256:fb95debbd1a824b2c4376932f2216cc186912e389bdb0e27147778cf6acb3f89"
],
"version": "==2020.4.4"
},
"six": {
"hashes": [
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
],
"version": "==1.14.0"
},
"termcolor": {
"hashes": [
"sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"
],
"version": "==1.1.0"
},
"toml": {
"hashes": [
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
],
"version": "==0.10.0"
},
"typed-ast": {
"hashes": [
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
],
"version": "==1.4.1"
},
"typing-extensions": {
"hashes": [
"sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5",
"sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae",
"sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"
],
"markers": "python_version < '3.8'",
"version": "==3.7.4.2"
},
"wcwidth": {
"hashes": [
"sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1",
"sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1"
],
"version": "==0.1.9"
},
"zipp": {
"hashes": [
"sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b",
"sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"
],
"version": "==3.1.0"
}
}
}
+1
View File
@@ -0,0 +1 @@
web: gunicorn -b :5000 lnbits:app -k gevent
+13 -11
View File
@@ -1,27 +1,28 @@
![Lightning network wallet](https://i.imgur.com/arUWZbH.png)
# LNbits
Simple free and open-source Python lightning-network wallet/accounts system. Use https://lnbits.com, or run your own LNbits server!
![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png)
# LNbits v0.1 BETA, free and open-source lightning-network wallet/accounts system
https://lnbits.com, or run your own LNbits server!
LNbits is a very simple server that sits on top of a funding source, and can be used as:
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet!
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
* Part of a development stack via LNbits API
* Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations
The wallet can run on top of any lightning-network funding source such as LND, lntxbot, paywall, opennode, etc. This first BETA release runs on top of lntxbot, other releases coming soon, although if you are impatient, it could be ported to other funding sources relatively easily. Contributors very welcome :)
The wallet can run on top of any lightning-network funding source, currently there is support for LND, CLightning, Lntxbot, LNpay, OpenNode, with more being added regularily.
LNbits is still in BETA. Please report any vulnerabilities responsibly
## LNbits as an account system
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv + more to come..
![Lightning network wallet](https://i.imgur.com/Sd4ri3T.png)
![Lightning network wallet](https://i.imgur.com/w8jdGpF.png)
Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
(LNbits M5StackSats available here https://github.com/arcbtc/M5StackSats)
![lnurl ATM](https://i.imgur.com/ABruzAn.png)
![lnurl ATM](https://i.imgur.com/WfCg8wY.png)
## 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.
@@ -29,7 +30,7 @@ LNURL has a fallback scheme, so if scanned by a regular QR code reader it can de
![lnurl fallback](https://i.imgur.com/CPBKHIv.png)
https://github.com/btcontract/lnurl-rfc/blob/master/spec.md
Adding **/lnurl?lightning="LNURL-WITHDRAW"** will trigger a withdraw that builds an LNbits wallet.
Adding **/lnurlwallet?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.
![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg)
@@ -38,7 +39,7 @@ Example use would be an ATM, which utilises LNURL, if the user scans the QR with
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".
![lnurl ATM](https://i.imgur.com/SF5KoIe.png)
![lnurl ATM](https://i.imgur.com/xFWDnwy.png)
# Running LNbits locally
Download this repo
@@ -61,6 +62,7 @@ You will need to set the variables in .env.example, and rename the file to .env
Running the server
------------------
$ flask migrate
$ flask run
There is an environment variable called `FLASK_ENV` that has to be set to `development`
+7
View File
@@ -0,0 +1,7 @@
{
"scripts": {
"dokku": {
"predeploy": "flask migrate"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g fill="#eee" font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 101.041 30.0001"><defs><path id="a" d="M33.2619 148.1667h154.2143v68.7917H33.2619z"/></defs><g fill="#1f2234" aria-label="LNbits" font-family="sans-serif" font-size=".3095" font-weight="400" letter-spacing=".0031" style="line-height:1.25;white-space:pre;shape-inside:url(#a)" transform="matrix(72.4607 0 0 72.4607 -2399.2814 -10741.3589)"><g transform="matrix(.00244 0 0 .00244 33.0708 148.1594)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g><g font-family="roboto"><path d="M33.6755 148.518h.0962v.0364h-.1416v-.22h.0454zM33.9796 148.5544h-.0453l-.0883-.1448v.1448h-.0453v-.22h.0453l.0884.145v-.145h.0452z" font-weight="700" style="-inkscape-font-specification:'roboto Bold'"/><path d="M34.1625 148.4744q0 .0375-.0172.0603-.0172.0227-.0462.0227-.031 0-.048-.0219l-.0014.019h-.0256v-.2322h.028v.0866q.0168-.021.0466-.021.0298 0 .0467.0225.017.0225.017.0617zm-.028-.003q0-.0286-.011-.044-.011-.0157-.0317-.0157-.0277 0-.0398.0257v.0707q.0129.0257.04.0257.0202 0 .0314-.0156.0112-.0156.0112-.0469zM34.231 148.5544h-.028v-.1635h.028zm-.0302-.2069q0-.007.0041-.0115.0042-.005.0124-.005.0082 0 .0124.005.0042.005.0042.0115 0 .007-.0042.0113-.0042.005-.0124.005-.0082 0-.0124-.005-.004-.005-.004-.0113zM34.3167 148.3513v.0396h.0305v.0216h-.0305v.1014q0 .01.004.0148.0042.005.014.005.0048 0 .0133-.002v.0227q-.011.003-.0215.003-.0187 0-.0283-.0113-.0095-.0113-.0095-.0322v-.1014h-.0297v-.0216h.0297v-.0396zM34.478 148.511q0-.0113-.0086-.0175-.0085-.006-.0298-.0109-.0211-.005-.0337-.0109-.0124-.006-.0184-.015t-.006-.0209q0-.02.017-.034.017-.0139.0435-.0139.0278 0 .045.0144.0174.0144.0174.0367h-.028q0-.0115-.0099-.0198-.0097-.008-.0245-.008-.0152 0-.0238.007-.0086.007-.0086.0174 0 .0101.008.0153.008.005.0288.01.021.005.034.0112.013.007.0192.0157.0063.009.0063.0222 0 .022-.0175.0352-.0175.0131-.0455.0131-.0197 0-.0348-.007-.015-.007-.0237-.0193-.0085-.0125-.0085-.027h.028q.0007.014.0111.0223.0106.008.0279.008.0158 0 .0253-.006.0097-.006.0097-.0172z" style="-inkscape-font-specification:roboto"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><g transform="translate(-2.9464 -5.625) scale(.17717)"><circle cx="101.2976" cy="116.4167" r="84.6667" fill="#673ab7" fill-rule="evenodd"/><path fill="#eee" d="M79.1105 71.9667v49.0613h13.3803v40.141l31.2208-53.5213h-17.8404l17.8404-35.681z"/></g></svg>

After

Width:  |  Height:  |  Size: 314 B

+47 -456
View File
@@ -1,39 +1,42 @@
import importlib
import json
import requests
import uuid
from flask import Flask, jsonify, redirect, render_template, request, url_for
from flask_talisman import Talisman
from lnurl import Lnurl, LnurlWithdrawResponse
from 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 . import bolt11
from .core import core_app
from .db import init_databases, open_db
from .helpers import ExtensionManager, megajson
from .settings import WALLET, DEFAULT_USER_WALLET_NAME, FEE_RESERVE
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__)
valid_extensions = [ext for ext in ExtensionManager().extensions if ext.is_valid]
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'",
"cdnjs.cloudflare.com",
"code.ionicframework.com",
"code.jquery.com",
"blob:",
"api.opennode.co",
"fonts.googleapis.com",
"fonts.gstatic.com",
"maxcdn.bootstrapcdn.com",
"github.com",
"avatars2.githubusercontent.com",
]
},
)
@@ -55,449 +58,37 @@ for ext in valid_extensions:
# filters
# -------
app.jinja_env.globals["DEBUG"] = app.config["DEBUG"]
app.jinja_env.globals["EXTENSIONS"] = valid_extensions
app.jinja_env.filters["megajson"] = megajson
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
# ----
@app.before_first_request
def init():
init_databases()
# vvvvvvvvvvvvvvvvvvvvvvvvvvv
# move the rest to `core_app`
# vvvvvvvvvvvvvvvvvvvvvvvvvvv
# vvvvvvvvvvvvvvvvvvvvvvvvvvv
@app.route("/deletewallet")
def deletewallet():
user_id = request.args.get("usr")
wallet_id = request.args.get("wal")
with open_db() as db:
db.execute(
"""
UPDATE wallets AS w
SET
user = 'del:' || w.user,
adminkey = 'del:' || w.adminkey,
inkey = 'del:' || w.inkey
WHERE id = ? AND user = ?
""",
(wallet_id, user_id),
)
next_wallet = db.fetchone("SELECT id FROM wallets WHERE user = ?", (user_id,))
if next_wallet:
return redirect(url_for("wallet", usr=user_id, wal=next_wallet[0]))
return redirect(url_for("home"))
@app.route("/lnurl")
def lnurl():
lnurl = request.args.get("lightning")
return render_template("lnurl.html", lnurl=lnurl)
@app.route("/lnurlwallet")
def lnurlwallet():
lnurl = Lnurl(request.args.get("lightning"))
r = requests.get(lnurl.url)
if not r.ok:
return redirect(url_for("home"))
data = json.loads(r.text)
if data.get("status") == "ERROR":
return redirect(url_for("home"))
withdraw_res = LnurlWithdrawResponse(**data)
_, pay_hash, pay_req = WALLET.create_invoice(withdraw_res.max_sats, "LNbits lnurl funding")
r = requests.get(
withdraw_res.callback.base,
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": pay_req}},
)
if not r.ok:
return redirect(url_for("home"))
data = json.loads(r.text)
for i in range(10):
r = WALLET.get_invoice_status(pay_hash).raw_response
if not r.ok:
continue
data = r.json()
break
with open_db() as db:
wallet_id = uuid.uuid4().hex
user_id = uuid.uuid4().hex
wallet_name = DEFAULT_USER_WALLET_NAME
adminkey = uuid.uuid4().hex
inkey = uuid.uuid4().hex
db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
db.execute(
"INSERT INTO wallets (id, name, user, adminkey, inkey) VALUES (?, ?, ?, ?, ?)",
(wallet_id, wallet_name, user_id, adminkey, inkey),
)
db.execute(
"INSERT INTO apipayments (payhash, amount, wallet, pending, memo) VALUES (?, ?, ?, 0, ?)",
(pay_hash, withdraw_res.max_sats * 1000, wallet_id, "LNbits lnurl funding",),
)
return redirect(url_for("wallet", usr=user_id, wal=wallet_id))
@app.route("/wallet")
def wallet():
usr = request.args.get("usr")
wallet_id = request.args.get("wal")
wallet_name = request.args.get("nme")
if usr:
if not len(usr) > 20:
return redirect(url_for("home"))
if wallet_id:
if not len(wallet_id) > 20:
return redirect(url_for("home"))
# just usr: return a the first user wallet or create one if none found
# usr and wallet_id: return that wallet or create it if it doesn't exist
# usr, wallet_id and wallet_name: same as above, but use the specified name
# usr and wallet_name: generate a wallet_id and create
# wallet_id and wallet_name: create a user, then move an existing wallet or create
# just wallet_name: create a user, then generate a wallet_id and create
# nothing: create everything
with open_db() as db:
# ensure this user exists
# -------------------------------
if not usr:
usr = uuid.uuid4().hex
return redirect(url_for("wallet", usr=usr, wal=wallet_id, nme=wallet_name))
db.execute(
"""
INSERT OR IGNORE INTO accounts (id) VALUES (?)
""",
(usr,),
)
user_wallets = db.fetchall("SELECT * FROM wallets WHERE user = ?", (usr,))
if not wallet_id:
if user_wallets and not wallet_name:
# fetch the first wallet from this user
# -------------------------------------
wallet_id = user_wallets[0]["id"]
else:
# create for this user
# --------------------
wallet_name = wallet_name or DEFAULT_USER_WALLET_NAME
wallet_id = uuid.uuid4().hex
db.execute(
"""
INSERT INTO wallets (id, name, user, adminkey, inkey)
VALUES (?, ?, ?, ?, ?)
""",
(wallet_id, wallet_name, usr, uuid.uuid4().hex, uuid.uuid4().hex),
)
return redirect(url_for("wallet", usr=usr, wal=wallet_id, nme=wallet_name))
# if wallet_id is given, try to move it to this user or create
# ------------------------------------------------------------
db.execute(
"""
INSERT OR REPLACE INTO wallets (id, user, name, adminkey, inkey)
VALUES (?, ?,
coalesce((SELECT name FROM wallets WHERE id = ?), ?),
coalesce((SELECT adminkey FROM wallets WHERE id = ?), ?),
coalesce((SELECT inkey FROM wallets WHERE id = ?), ?)
)
""",
(
wallet_id,
usr,
wallet_id,
wallet_name or DEFAULT_USER_WALLET_NAME,
wallet_id,
uuid.uuid4().hex,
wallet_id,
uuid.uuid4().hex,
),
)
# finally, get the wallet with balance and transactions
# -----------------------------------------------------
wallet = db.fetchone(
"""
SELECT
coalesce((SELECT balance/1000 FROM balances WHERE wallet = wallets.id), 0) * ? AS balance,
*
FROM wallets
WHERE user = ? AND id = ?
""",
(1 - FEE_RESERVE, usr, wallet_id),
)
transactions = db.fetchall(
"""
SELECT *
FROM apipayments
WHERE wallet = ? AND pending = 0
ORDER BY time
""",
(wallet_id,),
)
user_ext = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (usr,))
user_ext = [v[0] for v in user_ext]
return render_template(
"wallet.html", user_wallets=user_wallets, wallet=wallet, user=usr, transactions=transactions, user_ext=user_ext
)
@app.route("/api/v1/invoices", methods=["GET", "POST"])
def api_invoices():
if request.headers["Content-Type"] != "application/json":
return jsonify({"ERROR": "MUST BE JSON"}), 400
postedjson = request.json
# Form validation
if int(postedjson["value"]) < 0 or not postedjson["memo"].replace(" ", "").isalnum():
return jsonify({"ERROR": "FORM ERROR"}), 401
if "value" not in postedjson:
return jsonify({"ERROR": "NO VALUE"}), 400
if not postedjson["value"].isdigit():
return jsonify({"ERROR": "VALUE MUST BE A NUMBER"}), 400
if int(postedjson["value"]) < 0:
return jsonify({"ERROR": "AMOUNTLESS INVOICES NOT SUPPORTED"}), 400
if "memo" not in postedjson:
return jsonify({"ERROR": "NO MEMO"}), 400
with open_db() as db:
wallet = db.fetchone(
"SELECT id FROM wallets WHERE inkey = ? OR adminkey = ?",
(request.headers["Grpc-Metadata-macaroon"], request.headers["Grpc-Metadata-macaroon"],),
)
if not wallet:
return jsonify({"ERROR": "NO KEY"}), 200
r, pay_hash, pay_req = WALLET.create_invoice(postedjson["value"], postedjson["memo"])
if not r.ok or "error" in r.json():
return jsonify({"ERROR": "UNEXPECTED BACKEND ERROR"}), 500
amount_msat = int(postedjson["value"]) * 1000
db.execute(
"INSERT INTO apipayments (payhash, amount, wallet, pending, memo) VALUES (?, ?, ?, 1, ?)",
(pay_hash, amount_msat, wallet["id"], postedjson["memo"],),
)
return jsonify({"pay_req": pay_req, "payment_hash": pay_hash}), 200
@app.route("/api/v1/channels/transactions", methods=["GET", "POST"])
def api_transactions():
if request.headers["Content-Type"] != "application/json":
return jsonify({"ERROR": "MUST BE JSON"}), 400
data = request.json
print(data)
if "payment_request" not in data:
return jsonify({"ERROR": "NO PAY REQ"}), 400
with open_db() as db:
wallet = db.fetchone("SELECT id FROM wallets WHERE adminkey = ?", (request.headers["Grpc-Metadata-macaroon"],))
if not wallet:
return jsonify({"ERROR": "BAD AUTH"}), 401
# decode the invoice
invoice = bolt11.decode(data["payment_request"])
if invoice.amount_msat == 0:
return jsonify({"ERROR": "AMOUNTLESS INVOICES NOT SUPPORTED"}), 400
# insert the payment
db.execute(
"INSERT OR IGNORE INTO apipayments (payhash, amount, fee, wallet, pending, memo) VALUES (?, ?, ?, ?, 1, ?)",
(
invoice.payment_hash,
-int(invoice.amount_msat),
-int(invoice.amount_msat) * FEE_RESERVE,
wallet["id"],
invoice.description,
),
)
# check balance
balance = db.fetchone("SELECT balance/1000 FROM balances WHERE wallet = ?", (wallet["id"],))[0]
if balance < 0:
db.execute("DELETE FROM apipayments WHERE payhash = ? AND wallet = ?", (invoice.payment_hash, wallet["id"]))
return jsonify({"ERROR": "INSUFFICIENT BALANCE"}), 403
# check if the invoice is an internal one
if db.fetchone("SELECT count(*) FROM apipayments WHERE payhash = ?", (invoice.payment_hash,))[0] == 2:
# internal. mark both sides as fulfilled.
db.execute("UPDATE apipayments SET pending = 0, fee = 0 WHERE payhash = ?", (invoice.payment_hash,))
else:
# actually send the payment
r = WALLET.pay_invoice(data["payment_request"])
if not r.raw_response.ok or r.failed:
return jsonify({"ERROR": "UNEXPECTED PAYMENT ERROR"}), 500
# payment went through, not pending anymore, save actual fees
db.execute(
"UPDATE apipayments SET pending = 0, fee = ? WHERE payhash = ? AND wallet = ?",
(r.fee_msat, invoice.payment_hash, wallet["id"]),
)
return jsonify({"PAID": "TRUE", "payment_hash": invoice.payment_hash}), 200
@app.route("/api/v1/invoice/<payhash>", methods=["GET"])
def api_checkinvoice(payhash):
if request.headers["Content-Type"] != "application/json":
return jsonify({"ERROR": "MUST BE JSON"}), 400
with open_db() as db:
payment = db.fetchall("SELECT * FROM apipayments WHERE payhash = ?", (payhash,))
if not payment:
return jsonify({"ERROR": "NO INVOICE"}), 404
if not payment[0][4]: # pending
return jsonify({"PAID": "TRUE"}), 200
if not WALLET.get_invoice_status(payhash).settled:
return jsonify({"PAID": "FALSE"}), 200
db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,))
return jsonify({"PAID": "TRUE"}), 200
@app.route("/api/v1/payment/<payhash>", methods=["GET"])
def api_checkpayment(payhash):
if request.headers["Content-Type"] != "application/json":
return jsonify({"ERROR": "MUST BE JSON"}), 400
with open_db() as db:
payment = db.fetchone(
"""
SELECT pending
FROM apipayments
INNER JOIN wallets AS w ON apipayments.wallet = w.id
WHERE payhash = ?
AND (w.adminkey = ? OR w.inkey = ?)
""",
(payhash, request.headers["Grpc-Metadata-macaroon"], request.headers["Grpc-Metadata-macaroon"]),
)
if not payment:
return jsonify({"ERROR": "NO INVOICE"}), 404
if not payment["pending"]: # pending
return jsonify({"PAID": "TRUE"}), 200
if not WALLET.get_payment_status(payhash).settled:
return jsonify({"PAID": "FALSE"}), 200
db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,))
return jsonify({"PAID": "TRUE"}), 200
@app.route("/api/v1/checkpending", methods=["POST"])
def api_checkpending():
with open_db() as db:
for pendingtx in db.fetchall(
"""
SELECT
payhash,
CASE
WHEN amount < 0 THEN 'send'
ELSE 'recv'
END AS kind
FROM apipayments
INNER JOIN wallets ON apipayments.wallet = wallets.id
WHERE time > strftime('%s', 'now') - 86400
AND pending = 1
AND (adminkey = ? OR inkey = ?)
""",
(request.headers["Grpc-Metadata-macaroon"], request.headers["Grpc-Metadata-macaroon"]),
):
payhash = pendingtx["payhash"]
kind = pendingtx["kind"]
if kind == "send":
payment_complete = WALLET.get_payment_status(payhash).settled
if payment_complete:
db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,))
elif payment_complete is False:
db.execute("DELETE FROM apipayments WHERE payhash = ?", (payhash,))
elif kind == "recv" and WALLET.get_invoice_status(payhash).settled:
db.execute("UPDATE apipayments SET pending = 0 WHERE payhash = ?", (payhash,))
return ""
# Checks DB to see if the extensions are activated or not activated for the user
@app.route("/extensions")
def extensions():
usr = request.args.get("usr")
enable = request.args.get("enable")
disable = request.args.get("disable")
ext = None
if usr and not len(usr) > 20:
return redirect(url_for("home"))
if enable and disable:
# TODO: show some kind of error
return redirect(url_for("extensions"))
with open_db() as db:
user_wallets = db.fetchall("SELECT * FROM wallets WHERE user = ?", (usr,))
if enable:
ext, value = enable, 1
if disable:
ext, value = disable, 0
if ext:
db.execute(
"""
INSERT OR REPLACE INTO extensions (user, extension, active)
VALUES (?, ?, ?)
""",
(usr, ext, value),
)
user_ext = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (usr,))
user_ext = [v[0] for v in user_ext]
return render_template("extensions.html", user_wallets=user_wallets, user=usr, user_ext=user_ext)
if __name__ == "__main__":
app.run()
+7
View File
@@ -0,0 +1,7 @@
{
"scripts": {
"dokku": {
"predeploy": "flask migrate"
}
}
}
+2
View File
@@ -1,3 +1,5 @@
# type: ignore
import bitstring
import re
from binascii import hexlify
+4 -3
View File
@@ -1,8 +1,9 @@
from flask import Blueprint
core_app = Blueprint("core", __name__, template_folder="templates")
core_app: Blueprint = Blueprint("core", __name__, template_folder="templates", static_folder="static")
from .views_api import * # noqa
from .views import * # noqa
from .views.api import * # noqa
from .views.generic import * # noqa
from .views.lnurl import * # noqa
+208
View File
@@ -0,0 +1,208 @@
from typing import List, Optional
from uuid import uuid4
from lnbits.db import open_db
from lnbits.settings import DEFAULT_WALLET_NAME
from .models import User, Wallet, Payment
# accounts
# --------
def create_account() -> User:
with open_db() as db:
user_id = uuid4().hex
db.execute("INSERT INTO accounts (id) VALUES (?)", (user_id,))
new_account = get_account(user_id=user_id)
assert new_account, "Newly created account couldn't be retrieved"
return new_account
def get_account(user_id: str) -> Optional[User]:
with open_db() as db:
row = db.fetchone("SELECT id, email, pass as password FROM accounts WHERE id = ?", (user_id,))
return User(**row) if row else None
def get_user(user_id: str) -> Optional[User]:
with open_db() as db:
user = db.fetchone("SELECT id, email FROM accounts WHERE id = ?", (user_id,))
if user:
extensions = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (user_id,))
wallets = db.fetchall(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE user = ?
""",
(user_id,),
)
return (
User(**{**user, **{"extensions": [e[0] for e in extensions], "wallets": [Wallet(**w) for w in wallets]}})
if user
else None
)
def update_user_extension(*, user_id: str, extension: str, active: int) -> None:
with open_db() as db:
db.execute(
"""
INSERT OR REPLACE INTO extensions (user, extension, active)
VALUES (?, ?, ?)
""",
(user_id, extension, active),
)
# wallets
# -------
def create_wallet(*, user_id: str, wallet_name: Optional[str] = None) -> Wallet:
with open_db() as db:
wallet_id = uuid4().hex
db.execute(
"""
INSERT INTO wallets (id, name, user, adminkey, inkey)
VALUES (?, ?, ?, ?, ?)
""",
(wallet_id, wallet_name or DEFAULT_WALLET_NAME, user_id, uuid4().hex, uuid4().hex),
)
new_wallet = get_wallet(wallet_id=wallet_id)
assert new_wallet, "Newly created wallet couldn't be retrieved"
return new_wallet
def delete_wallet(*, user_id: str, wallet_id: str) -> None:
with open_db() as db:
db.execute(
"""
UPDATE wallets AS w
SET
user = 'del:' || w.user,
adminkey = 'del:' || w.adminkey,
inkey = 'del:' || w.inkey
WHERE id = ? AND user = ?
""",
(wallet_id, user_id),
)
def get_wallet(wallet_id: str) -> Optional[Wallet]:
with open_db() as db:
row = db.fetchone(
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE id = ?
""",
(wallet_id,),
)
return Wallet(**row) if row else None
def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
with open_db() as db:
check_field = "adminkey" if key_type == "admin" else "inkey"
row = db.fetchone(
f"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE {check_field} = ?
""",
(key,),
)
return Wallet(**row) if row else None
# wallet payments
# ---------------
def get_wallet_payment(wallet_id: str, checking_id: str) -> Optional[Payment]:
with open_db() as db:
row = db.fetchone(
"""
SELECT payhash as checking_id, amount, fee, pending, memo, time
FROM apipayments
WHERE wallet = ? AND payhash = ?
""",
(wallet_id, checking_id),
)
return Payment(**row) if row else None
def get_wallet_payments(wallet_id: str, *, include_all_pending: bool = False) -> List[Payment]:
with open_db() as db:
if include_all_pending:
clause = "pending = 1"
else:
clause = "((amount > 0 AND pending = 0) OR amount < 0)"
rows = db.fetchall(
f"""
SELECT payhash as checking_id, amount, fee, pending, memo, time
FROM apipayments
WHERE wallet = ? AND {clause}
ORDER BY time DESC
""",
(wallet_id,),
)
return [Payment(**row) for row in rows]
def delete_wallet_payments_expired(wallet_id: str, *, seconds: int = 86400) -> None:
with open_db() as db:
db.execute(
"""
DELETE
FROM apipayments WHERE wallet = ? AND pending = 1 AND time < strftime('%s', 'now') - ?
""",
(wallet_id, seconds),
)
# payments
# --------
def create_payment(
*, wallet_id: str, checking_id: str, amount: int, memo: str, fee: int = 0, pending: bool = True
) -> Payment:
with open_db() as db:
db.execute(
"""
INSERT INTO apipayments (wallet, payhash, amount, pending, memo, fee)
VALUES (?, ?, ?, ?, ?, ?)
""",
(wallet_id, checking_id, amount, int(pending), memo, fee),
)
new_payment = get_wallet_payment(wallet_id, checking_id)
assert new_payment, "Newly created payment couldn't be retrieved"
return new_payment
def update_payment_status(checking_id: str, pending: bool) -> None:
with open_db() as db:
db.execute("UPDATE apipayments SET pending = ? WHERE payhash = ?", (int(pending), checking_id,))
def delete_payment(checking_id: str) -> None:
with open_db() as db:
db.execute("DELETE FROM apipayments WHERE payhash = ?", (checking_id,))
+75
View File
@@ -0,0 +1,75 @@
from lnbits.db import open_db
def m001_initial(db):
"""
Initial LNbits tables.
"""
db.execute(
"""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
email TEXT,
pass TEXT
);
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS extensions (
user TEXT NOT NULL,
extension TEXT NOT NULL,
active BOOLEAN DEFAULT 0,
UNIQUE (user, extension)
);
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
user TEXT NOT NULL,
adminkey TEXT NOT NULL,
inkey TEXT
);
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS apipayments (
payhash TEXT NOT NULL,
amount INTEGER NOT NULL,
fee INTEGER NOT NULL DEFAULT 0,
wallet TEXT NOT NULL,
pending BOOLEAN NOT NULL,
memo TEXT,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE (wallet, payhash)
);
"""
)
db.execute(
"""
CREATE VIEW IF NOT EXISTS balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming
FROM apipayments
WHERE amount > 0 AND pending = 0 -- don't sum pending
GROUP BY wallet
UNION ALL
SELECT wallet, SUM(amount + fee) AS s -- outgoing, sum fees
FROM apipayments
WHERE amount < 0 -- do sum pending
GROUP BY wallet
)
GROUP BY wallet;
"""
)
def migrate():
with open_db() as db:
m001_initial(db)
+80
View File
@@ -0,0 +1,80 @@
from typing import List, NamedTuple, Optional
class User(NamedTuple):
id: str
email: str
extensions: List[str] = []
wallets: List["Wallet"] = []
password: Optional[str] = None
@property
def wallet_ids(self) -> List[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional["Wallet"]:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
class Wallet(NamedTuple):
id: str
name: str
user: str
adminkey: str
inkey: str
balance_msat: int
@property
def balance(self) -> int:
return self.balance // 1000
def get_payment(self, checking_id: str) -> Optional["Payment"]:
from .crud import get_wallet_payment
return get_wallet_payment(self.id, checking_id)
def get_payments(self, *, include_all_pending: bool = False) -> List["Payment"]:
from .crud import get_wallet_payments
return get_wallet_payments(self.id, include_all_pending=include_all_pending)
def delete_expired_payments(self, seconds: int = 86400) -> None:
from .crud import delete_wallet_payments_expired
delete_wallet_payments_expired(self.id, seconds=seconds)
class Payment(NamedTuple):
checking_id: str
pending: bool
amount: int
fee: int
memo: str
time: int
@property
def msat(self) -> int:
return self.amount
@property
def sat(self) -> int:
return self.amount // 1000
@property
def is_in(self) -> bool:
return self.amount > 0
@property
def is_out(self) -> bool:
return self.amount < 0
def set_pending(self, pending: bool) -> None:
from .crud import update_payment_status
update_payment_status(self.checking_id, pending)
def delete(self) -> None:
from .crud import delete_payment
delete_payment(self.checking_id)
+73
View File
@@ -0,0 +1,73 @@
from typing import Optional, Tuple
from lnbits.bolt11 import decode as bolt11_decode # type: ignore
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import WALLET
from .crud import get_wallet, create_payment, delete_payment
def create_invoice(*, wallet_id: str, amount: int, memo: str) -> Tuple[str, str]:
try:
ok, checking_id, payment_request, error_message = WALLET.create_invoice(amount=amount, memo=memo)
except Exception as e:
ok, error_message = False, str(e)
if not ok:
raise Exception(error_message or "Unexpected backend error.")
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(
wallet_id=wallet_id,
checking_id=checking_id,
amount=-invoice.amount_msat,
fee=fee_msat,
memo=invoice.description,
)
except Exception as e:
ok, error_message = False, str(e)
delete_payment(temp_id)
if not ok:
raise Exception(error_message or "Unexpected backend error.")
return checking_id
def check_payment(*, checking_id: str) -> str:
pass
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+4
View File
@@ -0,0 +1,4 @@
new Vue({
el: '#vue',
mixins: [windowMixin]
});
+25
View File
@@ -0,0 +1,25 @@
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
disclaimerDialog: {
show: false,
data: {}
},
walletName: ''
};
},
methods: {
createWallet: function () {
LNbits.href.createWallet(this.walletName);
},
processing: function () {
this.$q.notify({
timeout: 0,
message: 'Processing...',
icon: null
});
}
}
});
+357
View File
@@ -0,0 +1,357 @@
Vue.component(VueQrcode.name, VueQrcode);
Vue.use(VueQrcodeReader);
function generateChart(canvas, payments) {
var txs = [];
var n = 0;
var data = {
labels: [],
income: [],
outcome: [],
cumulative: []
};
_.each(payments.slice(0).sort(function (a, b) {
return a.time - b.time;
}), function (tx) {
txs.push({
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
sat: tx.sat,
});
});
_.each(_.groupBy(txs, 'hour'), function (value, day) {
var income = _.reduce(value, function(memo, tx) {
return (tx.sat >= 0) ? memo + tx.sat : memo;
}, 0);
var outcome = _.reduce(value, function(memo, tx) {
return (tx.sat < 0) ? memo + Math.abs(tx.sat) : memo;
}, 0);
n = n + income - outcome;
data.labels.push(day);
data.income.push(income);
data.outcome.push(outcome);
data.cumulative.push(n);
});
new Chart(canvas.getContext('2d'), {
type: 'bar',
data: {
labels: data.labels,
datasets: [
{
data: data.cumulative,
type: 'line',
label: 'balance',
backgroundColor: '#673ab7', // deep-purple
borderColor: '#673ab7',
borderWidth: 4,
pointRadius: 3,
fill: false
},
{
data: data.income,
type: 'bar',
label: 'in',
barPercentage: 0.75,
backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
},
{
data: data.outcome,
type: 'bar',
label: 'out',
barPercentage: 0.75,
backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink
}
]
},
options: {
title: {
text: 'Chart.js Combo Time Scale'
},
tooltips: {
mode: 'index',
intersect:false
},
scales: {
xAxes: [{
type: 'time',
display: true,
offset: true,
time: {
minUnit: 'hour',
stepSize: 3
}
}],
},
// performance tweaks
animation: {
duration: 0
},
elements: {
line: {
tension: 0
}
}
}
});
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
receive: {
show: false,
status: 'pending',
paymentReq: null,
data: {
amount: null,
memo: ''
}
},
send: {
show: false,
invoice: null,
data: {
bolt11: ''
}
},
sendCamera: {
show: false,
camera: 'auto'
},
payments: [],
paymentsTable: {
columns: [
{name: 'memo', align: 'left', label: 'Memo', field: 'memo'},
{name: 'date', align: 'left', label: 'Date', field: 'date', sortable: true},
{name: 'sat', align: 'right', label: 'Amount (sat)', field: 'sat', sortable: true}
],
pagination: {
rowsPerPage: 10
},
filter: null
},
paymentsChart: {
show: false
},
disclaimerDialog: {
show: false,
location: window.location
}
};
},
computed: {
filteredPayments: function () {
var q = this.paymentsTable.filter;
if (!q || q == '') return this.payments;
return LNbits.utils.search(this.payments, q);
},
balance: function () {
if (this.payments.length) {
return _.pluck(this.payments, 'amount').reduce(function (a, b) { return a + b; }, 0) / 1000;
}
return this.g.wallet.sat;
},
fbalance: function () {
return LNbits.utils.formatSat(this.balance)
},
canPay: function () {
if (!this.send.invoice) return false;
return this.send.invoice.sat <= this.balance;
},
pendingPaymentsExist: function () {
return (this.payments)
? _.where(this.payments, {pending: 1}).length > 0
: false;
}
},
methods: {
closeCamera: function () {
this.sendCamera.show = false;
},
showCamera: function () {
this.sendCamera.show = true;
},
showChart: function () {
this.paymentsChart.show = true;
this.$nextTick(function () {
generateChart(this.$refs.canvas, this.payments);
});
},
showReceiveDialog: function () {
this.receive = {
show: true,
status: 'pending',
paymentReq: null,
data: {
amount: null,
memo: ''
},
paymentChecker: null
};
},
showSendDialog: function () {
this.send = {
show: true,
invoice: null,
data: {
bolt11: ''
},
paymentChecker: null
};
},
closeReceiveDialog: function () {
var checker = this.receive.paymentChecker;
setTimeout(function () {
clearInterval(checker);
}, 10000);
},
closeSendDialog: function () {
this.sendCamera.show = false;
var checker = this.send.paymentChecker;
setTimeout(function () {
clearInterval(checker);
}, 1000);
},
createInvoice: function () {
var self = this;
this.receive.status = 'loading';
LNbits.api.createInvoice(this.g.wallet, this.receive.data.amount, this.receive.data.memo)
.then(function (response) {
self.receive.status = 'success';
self.receive.paymentReq = response.data.payment_request;
self.receive.paymentChecker = setInterval(function () {
LNbits.api.getPayment(self.g.wallet, response.data.checking_id).then(function (response) {
if (response.data.paid) {
self.fetchPayments();
self.receive.show = false;
clearInterval(self.receive.paymentChecker);
}
});
}, 2000);
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
self.receive.status = 'pending';
});
},
decodeQR: function (res) {
this.send.data.bolt11 = res;
this.decodeInvoice();
this.sendCamera.show = false;
},
decodeInvoice: function () {
try {
var invoice = decode(this.send.data.bolt11);
} catch (error) {
this.$q.notify({
timeout: 3000,
type: 'warning',
message: error + '.',
caption: '400 BAD REQUEST',
icon: null
});
return;
}
var cleanInvoice = {
msat: invoice.human_readable_part.amount,
sat: invoice.human_readable_part.amount / 1000,
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000)
};
_.each(invoice.data.tags, function (tag) {
if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description == 'payment_hash') { cleanInvoice.hash = tag.value; }
else if (tag.description == 'description') { cleanInvoice.description = tag.value; }
else if (tag.description == 'expiry') {
var expireDate = new Date((invoice.data.time_stamp + tag.value) * 1000);
cleanInvoice.expireDate = Quasar.utils.date.formatDate(expireDate, 'YYYY-MM-DDTHH:mm:ss.SSSZ');
cleanInvoice.expired = false; // TODO
}
}
});
this.send.invoice = Object.freeze(cleanInvoice);
},
payInvoice: function () {
var self = this;
dismissPaymentMsg = this.$q.notify({
timeout: 0,
message: 'Processing payment...',
icon: null
});
LNbits.api.payInvoice(this.g.wallet, this.send.data.bolt11).then(function (response) {
self.send.paymentChecker = setInterval(function () {
LNbits.api.getPayment(self.g.wallet, response.data.checking_id).then(function (res) {
if (res.data.paid) {
self.send.show = false;
clearInterval(self.send.paymentChecker);
dismissPaymentMsg();
self.fetchPayments();
}
});
}, 2000);
}).catch(function (error) {
dismissPaymentMsg();
LNbits.utils.notifyApiError(error);
});
},
deleteWallet: function (walletId, user) {
LNbits.utils.confirmDialog(
'Are you sure you want to delete this wallet?'
).onOk(function () {
LNbits.href.deleteWallet(walletId, user);
});
},
fetchPayments: function (checkPending) {
var self = this;
return LNbits.api.getPayments(this.g.wallet, checkPending).then(function (response) {
self.payments = response.data.map(function (obj) {
return LNbits.map.payment(obj);
}).sort(function (a, b) {
return b.time - a.time;
});
});
},
checkPendingPayments: function () {
var dismissMsg = this.$q.notify({
timeout: 0,
message: 'Checking pending transactions...',
icon: null
});
this.fetchPayments(true).then(function () {
dismissMsg();
});
},
exportCSV: function () {
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments);
}
},
watch: {
'payments': function () {
EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance]);
}
},
created: function () {
this.fetchPayments();
setTimeout(this.checkPendingPayments(), 1200);
},
mounted: function () {
if (this.$refs.disclaimer && !this.$q.localStorage.getItem('lnbits.disclaimerShown')) {
this.disclaimerDialog.show = true;
this.$q.localStorage.set('lnbits.disclaimerShown', true);
}
}
});
+51
View File
@@ -0,0 +1,51 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="Create an invoice (incoming)">
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "<i>{{ wallet.inkey }}</i>"}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": false, "amount": &lt;int&gt;, "memo": &lt;string&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>
<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>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Pay an invoice (outgoing)">
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/payments</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.adminkey }}"}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"out": true, "bolt11": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 CREATED (application/json)</h5>
<code>{"checking_id": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": true, "bolt11": &lt;string&gt;}' -H "X-Api-Key: {{ wallet.adminkey }}"</i> -H "Content-type: application/json"</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Check an invoice (incoming or outgoing)"
class="q-pb-md">
<q-card>
<q-card-section>
<code><span class="text-light-blue">GET</span> /api/v1/payments/&lt;checking_id&gt;</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": "{{ wallet.inkey }}"}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK (application/json)</h5>
<code>{"paid": &lt;bool&gt;}</code>
<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>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
{% assets filters='rjsmin', output='__bundle__/core/extensions.js',
'core/js/extensions.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-6 col-md-4 col-lg-3" v-for="extension in g.extensions" :key="extension.code">
<q-card>
<q-card-section>
<q-icon :name="extension.icon" color="grey-5" style="font-size: 4rem;"></q-icon>
{% raw %}
<h5 class="q-mt-lg q-mb-xs">{{ extension.name }}</h5>
{{ extension.shortDescription }}
{% endraw %}
</q-card-section>
<q-separator></q-separator>
<q-card-actions>
<div v-if="extension.isEnabled">
<q-btn flat color="deep-purple"
type="a" :href="[extension.url, '?usr=', g.user.id].join('')">Open</q-btn>
<q-btn flat color="grey-5"
type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&disable=', extension.code].join('')"> Disable</q-btn>
</div>
<q-btn v-else flat color="deep-purple"
type="a"
:href="['{{ url_for('core.extensions') }}', '?usr=', g.user.id, '&enable=', extension.code].join('')">
Enable</q-btn>
</q-card-actions>
</q-card>
</div>
</div>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
{% extends "public.html" %}
{% block scripts %}
{% assets filters='rjsmin', output='__bundle__/core/index.js',
'core/js/index.js' %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
{% if lnurl %}
<q-btn unelevated
color="deep-purple"
@click="processing"
type="a" href="{{ url_for('core.lnurlwallet', lightning=lnurl) }}">
Press to claim bitcoin
</q-btn>
{% else %}
<q-form @submit="createWallet" class="q-gutter-md">
<q-input filled dense
v-model="walletName"
label="Name your LNbits wallet *"
></q-input>
<q-btn unelevated
color="deep-purple"
:disable="walletName == ''"
type="submit">Add a new wallet</q-btn>
</q-form>
{% endif %}
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<h3 class="q-my-none"><strong>LN</strong>bits</h3>
<h5 class="q-my-md">Free and open-source lightning wallet</h5>
<p>Easy to set up and lightweight, LNbits can run on any lightning-network funding source, currently supporting LND, c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself!</p>
<p>You can run LNbits for yourself, or easily offer a custodian solution for others.</p>
<p>Each wallet has its own API keys and there is no limit to the number of wallets you can make. Being able to partition funds makes LNbits a useful tool for money management and as a development tool.</p>
<p>Extensions add extra functionality to LNbits so you can experiment with a range of cutting-edge technologies on the lightning network. We have made developing extensions as easy as possible, and as a free and open-source project, we encourage people to develop and submit their own.</p>
<div class="row q-mt-md q-gutter-sm">
<q-btn outline
color="grey"
type="a" href="https://github.com/arcbtc/lnbits" target="_blank" rel="noopener">View project in GitHub</q-btn>
<q-btn outline
color="grey"
type="a" href="https://paywall.link/to/f4e4e" target="_blank" rel="noopener">Donate</q-btn>
</div>
</q-card-section>
</q-card>
</div>
<!-- Ads -->
<div class="col-12 col-md-3 col-lg-3">
<div class="row q-col-gutter-lg justify-center">
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
<q-btn flat color="purple" label="Supporters" type="a" href="mailto:lnbits@pm.me" class="full-width"></q-btn>
<a href="https://github.com/rootzoll/raspiblitz">
<q-img contain :src="($q.dark.isActive) ? '{{ url_for('static', filename='images/raspiblitz-negative.svg') }}' : '{{ url_for('static', filename='images/raspiblitz.svg') }}'" style="height: 90px;"></q-img>
</a>
</div>
</div>
</div>
</div>
{% endblock %}
+259
View File
@@ -0,0 +1,259 @@
{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block styles %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='vendor/vue-qrcode-reader@2.1.1/vue-qrcode-reader.min.css') }}">
{% 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">
<q-card>
<q-card-section>
<h3 class="q-my-none"><strong>{% raw %}{{ fbalance }}{% endraw %}</strong> sat</h3>
</q-card-section>
<div class="row q-pb-md q-px-md q-col-gutter-md">
<div class="col">
<q-btn unelevated
color="purple"
class="full-width"
@click="showSendDialog">Send</q-btn>
</div>
<div class="col">
<q-btn unelevated
color="deep-purple"
class="full-width"
@click="showReceiveDialog">Receive</q-btn>
</div>
</div>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-sm">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Transactions</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
<q-tooltip>Check pending</q-tooltip>
</q-btn>-->
<q-btn dense flat round icon="show_chart" color="grey" @click="showChart">
<q-tooltip>Show chart</q-tooltip>
</q-btn>
</div>
</div>
<q-input v-if="payments.length > 10" filled dense clearable
v-model="paymentsTable.filter"
debounce="300" placeholder="Search by memo, amount" class="q-mb-md">
</q-input>
<q-table dense flat
:data="filteredPayments"
row-key="payhash"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props">{{ col.label }}</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width class="lnbits__q-table__icon-td">
<q-icon v-if="props.row.isPaid" size="14px"
:name="(props.row.sat < 0) ? 'call_made' : 'call_received'"
:color="(props.row.sat < 0) ? 'pink' : 'green'"></q-icon>
<q-icon v-else name="settings_ethernet" color="grey">
<q-tooltip>Pending</q-tooltip>
</q-icon>
</q-td>
<q-td key="memo" :props="props">
{{ props.row.memo }}
</q-td>
<q-td auto-width key="date" :props="props">
{{ props.row.date }}
</q-td>
<q-td auto-width key="sat" :props="props">
{{ props.row.fsat }}
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn flat color="grey" @click="exportCSV" class="float-right">Renew keys</q-btn>
<h6 class="text-subtitle1 q-mt-none q-mb-sm">LNbits wallet</h6>
<strong>Wallet name: </strong><em>{{ wallet.name }}</em><br>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em><br>
<strong>Admin key: </strong><em>{{ wallet.adminkey }}</em><br>
<strong>Invoice/read key: </strong><em>{{ wallet.inkey }}</em>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "core/_api_docs.html" %}
<q-separator></q-separator>
<q-expansion-item
group="extras"
icon="remove_circle"
label="Delete wallet">
<q-card>
<q-card-section>
<p>This whole wallet will be deleted, the funds will be <strong>UNRECOVERABLE</strong>.</p>
<q-btn unelevated
color="red-10"
@click="deleteWallet('{{ wallet.id }}', '{{ user.id }}')">Delete wallet</q-btn>
</q-card-section>
</q-card>
</q-expansion-item>
</q-list>
</q-card-section>
</q-card>
</div>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card v-if="!receive.paymentReq" class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createInvoice" class="q-gutter-md">
<q-input filled dense
v-model.number="receive.data.amount"
type="number"
label="Amount (sat) *"></q-input>
<q-input filled dense
v-model.trim="receive.data.memo"
label="Memo"
placeholder="LNbits invoice"></q-input>
<div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn unelevated
color="deep-purple"
:disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit">Create invoice</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<q-spinner v-if="receive.status == 'loading'" color="deep-purple" size="2.55em"></q-spinner>
</q-form>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode :value="receive.paymentReq" :options="{width: 340}" class="rounded-borders"></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)">Copy invoice</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="send.show" position="top" @hide="closeSendDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="!send.invoice">
<q-form v-if="!sendCamera.show" @submit="decodeInvoice" class="q-gutter-md">
<q-input filled dense
v-model.trim="send.data.bolt11"
type="textarea"
label="Paste an invoice *">
<template v-slot:after>
<q-btn round dense flat icon="photo_camera" @click="showCamera">
<q-tooltip>Use camera to scan an invoice</q-tooltip>
</q-btn>
</template>
</q-input>
<div class="row q-mt-lg">
<q-btn unelevated
color="deep-purple"
:disable="send.data.bolt11 == ''"
type="submit">Read invoice</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream @decode="decodeQR" class="rounded-borders"></qrcode-stream>
</q-responsive>
<div class="row q-mt-lg">
<q-btn @click="closeCamera" flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
</div>
<div v-else>
{% raw %}
<h6 class="q-my-none">{{ send.invoice.fsat }} sat</h6>
<q-separator class="q-my-sm"></q-separator>
<p style="word-break: break-all">
<strong>Memo:</strong> {{ send.invoice.description }}<br>
<strong>Expire date:</strong> {{ send.invoice.expireDate }}<br>
<strong>Hash:</strong> {{ send.invoice.hash }}
</p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg">
<q-btn unelevated
color="deep-purple"
@click="payInvoice">Send satoshis</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
<div v-else class="row q-mt-lg">
<q-btn unelevated disabled color="yellow" text-color="black">Not enough funds!</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="paymentsChart.show" position="top">
<q-card class="q-pa-sm" style="width: 800px; max-width: unset">
<q-card-section>
<canvas ref="canvas" width="600" height="400"></canvas>
</q-card-section>
</q-card>
</q-dialog>
{% if service_fee > 0 %}
<div ref="disclaimer"></div>
<q-dialog v-model="disclaimerDialog.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-deep-purple">Warning</h6>
<p>Login functionality to be released in v0.2, for now, <strong>make sure you bookmark this page for future access to your wallet</strong>!</p>
<p>This service is in BETA, and we hold no responsibility for people losing access to funds. To encourage you to run your own LNbits installation, any balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will incur a charge of <strong>{{ service_fee }}% service fee</strong> per week.</p>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(disclaimerDialog.location.href)">Copy wallet URL</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">I understand</q-btn>
</div>
</q-card>
</q-dialog>
{% endif %}
{% endblock %}
-98
View File
@@ -1,98 +0,0 @@
<!-- @format -->
{% extends "base.html" %} {% block menuitems %}
<li><a href="https://where39.com/"><p>Where39 anon locations</p><img src="static/where39.png" style="width:170px"></a></li>
<li><a href="https://github.com/arcbtc/Quickening"><p>The Quickening <$8 PoS</p><img src="static/quick.gif" style="width:170px"></a></li>
<li><a href="http://jigawatt.co/"><p>Buy BTC stamps + electronics</p><img src="static/stamps.jpg" style="width:170px"></a></li>
<li><a href="mailto:ben@arc.wales"><h3>Advertise here!</h3></a></li>
{% endblock %} {% block body %}
<!-- Right side column. Contains the navbar and content of the page -->
<div class="content-wrapper">
<!-- Content Header (Page header) -->
<section class="content-header">
<ol class="breadcrumb">
<li>
<a href="{{ url_for('core.home') }}"><i class="fa fa-dashboard"></i> Home</a>
</li>
</ol>
<br /><br />
<div class="row">
<div class="col-md-6">
<div class="alert alert-danger alert-dismissable">
<h4>
TESTING ONLY - wallet is still in BETA and very unstable
</h4>
</div></div></div>
</section>
<!-- Main content -->
<section class="content">
<div class="row">
<div class="col-md-3">
<!-- Default box -->
<div class="box">
<div class="box-header">
{% block call_to_action %}
<h1>
<small>Make a wallet</small>
</h1>
<div class="form-group">
<input
type="text"
class="form-control"
id="walname"
placeholder="Name your LNBits wallet"
required
/>
</div>
<button type="button" class="btn btn-primary" onclick="newwallet()">
Submit
</button>
{% endblock %}
</div>
<!-- /.box-body -->
</div>
<!-- /.box -->
</div>
</div>
<div class="row">
<div class="col-md-6">
<!-- Default box -->
<div class="box">
<div class="box-header">
<h1>
<a href="index.html" class="logo"><b>LN</b>bits</a>
<small>free and open-source lightning wallet</small>
</h1>
<p>
LNbits is a simple, free and open-source lightning-network wallet
for bits and bobs. You can run it on your own server, or use this
one.
<br /><br />
The wallet can be used in a variety of ways, an instant wallet for
LN demonstrations, a fallback wallet for the LNURL scheme, an
accounts system to mitigate the risk of exposing applications to
your full balance.
<br /><br />
The wallet can run on top of LND, lntxbot, paywall, opennode
<br /><br />
Please note that although one of the aims of this wallet is to
mitigate exposure of all your funds, its still very BETA and may
in fact do the opposite!
<br />
<a href="https://github.com/arcbtc/lnbits"
>https://github.com/arcbtc/lnbits</a
>
</p>
</div>
<!-- /.box-body -->
</div>
<!-- /.box -->
</div>
</div>
</section>
<!-- /.content -->
</div>
<!-- /.content-wrapper -->
{% endblock %}
-14
View File
@@ -1,14 +0,0 @@
from flask import render_template, send_from_directory
from os import path
from lnbits.core import core_app
@core_app.route("/favicon.ico")
def favicon():
return send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
@core_app.route("/")
def home():
return render_template("index.html")
+89
View File
@@ -0,0 +1,89 @@
from flask import g, jsonify, request
from lnbits.core import core_app
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.helpers import Status
from lnbits.settings import WALLET
from ..services import create_invoice, pay_invoice
@core_app.route("/api/v1/payments", methods=["GET"])
@api_check_wallet_key("invoice")
def api_payments():
if "check_pending" in request.args:
g.wallet.delete_expired_payments()
for payment in g.wallet.get_payments(include_all_pending=True):
if payment.is_out:
payment.set_pending(WALLET.get_payment_status(payment.checking_id).pending)
else:
payment.set_pending(WALLET.get_invoice_status(payment.checking_id).pending)
return jsonify(g.wallet.get_payments()), Status.OK
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"amount": {"type": "integer", "min": 1, "required": True},
"memo": {"type": "string", "empty": False, "required": True},
}
)
def api_payments_create_invoice():
try:
checking_id, payment_request = create_invoice(
wallet_id=g.wallet.id, amount=g.data["amount"], memo=g.data["memo"]
)
except Exception as e:
return jsonify({"message": str(e)}), Status.INTERNAL_SERVER_ERROR
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), Status.CREATED
@api_check_wallet_key("admin")
@api_validate_post_request(schema={"bolt11": {"type": "string", "empty": False, "required": True}})
def api_payments_pay_invoice():
try:
checking_id = pay_invoice(wallet_id=g.wallet.id, bolt11=g.data["bolt11"])
except ValueError as e:
return jsonify({"message": str(e)}), Status.BAD_REQUEST
except PermissionError as e:
return jsonify({"message": str(e)}), Status.FORBIDDEN
except Exception as e:
return jsonify({"message": str(e)}), Status.INTERNAL_SERVER_ERROR
return jsonify({"checking_id": checking_id}), Status.CREATED
@core_app.route("/api/v1/payments", methods=["POST"])
@api_validate_post_request(schema={"out": {"type": "boolean", "required": True}})
def api_payments_create():
if g.data["out"] is True:
return api_payments_pay_invoice()
return api_payments_create_invoice()
@core_app.route("/api/v1/payments/<checking_id>", methods=["GET"])
@api_check_wallet_key("invoice")
def api_payment(checking_id):
payment = g.wallet.get_payment(checking_id)
if not payment:
return jsonify({"message": "Payment does not exist."}), Status.NOT_FOUND
elif not payment.pending:
return jsonify({"paid": True}), Status.OK
try:
if payment.is_out:
is_paid = not WALLET.get_payment_status(checking_id).pending
elif payment.is_in:
is_paid = not WALLET.get_invoice_status(checking_id).pending
except Exception:
return jsonify({"paid": False}), Status.OK
if is_paid:
payment.set_pending(False)
return jsonify({"paid": True}), Status.OK
return jsonify({"paid": False}), Status.OK
+95
View File
@@ -0,0 +1,95 @@
from flask import g, abort, redirect, request, render_template, send_from_directory, url_for
from os import path
from lnbits.core import core_app
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.helpers import Status
from lnbits.settings import SERVICE_FEE
from ..crud import (
create_account,
get_user,
update_user_extension,
create_wallet,
delete_wallet,
)
@core_app.route("/favicon.ico")
def favicon():
return send_from_directory(path.join(core_app.root_path, "static"), "favicon.ico")
@core_app.route("/")
def home():
return render_template("core/index.html", lnurl=request.args.get("lightning", None))
@core_app.route("/extensions")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def extensions():
extension_to_enable = request.args.get("enable", type=str)
extension_to_disable = request.args.get("disable", type=str)
if extension_to_enable and extension_to_disable:
abort(Status.BAD_REQUEST, "You can either `enable` or `disable` an extension.")
if extension_to_enable:
update_user_extension(user_id=g.user.id, extension=extension_to_enable, active=1)
elif extension_to_disable:
update_user_extension(user_id=g.user.id, extension=extension_to_disable, active=0)
return render_template("core/extensions.html", user=get_user(g.user.id))
@core_app.route("/wallet")
@validate_uuids(["usr", "wal"])
def wallet():
user_id = request.args.get("usr", type=str)
wallet_id = request.args.get("wal", type=str)
wallet_name = request.args.get("nme", type=str)
service_fee = int(SERVICE_FEE) if int(SERVICE_FEE) == SERVICE_FEE else SERVICE_FEE
# just wallet_name: create a new user, then create a new wallet for user with wallet_name
# just user_id: return the first user wallet or create one if none found (with default wallet_name)
# user_id and wallet_name: create a new wallet for user with wallet_name
# user_id and wallet_id: return that wallet if user is the owner
# nothing: create everything
if not user_id:
user = get_user(create_account().id)
else:
user = get_user(user_id) or abort(Status.NOT_FOUND, "User does not exist.")
if not wallet_id:
if user.wallets and not wallet_name:
wallet = user.wallets[0]
else:
wallet = create_wallet(user_id=user.id, wallet_name=wallet_name)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
if wallet_id not in user.wallet_ids:
abort(Status.FORBIDDEN, "Not your wallet.")
return render_template("core/wallet.html", user=user, wallet=user.get_wallet(wallet_id), service_fee=service_fee)
@core_app.route("/deletewallet")
@validate_uuids(["usr", "wal"], required=True)
@check_user_exists()
def deletewallet():
wallet_id = request.args.get("wal", type=str)
user_wallet_ids = g.user.wallet_ids
if wallet_id not in user_wallet_ids:
abort(Status.FORBIDDEN, "Not your wallet.")
else:
delete_wallet(user_id=g.user.id, wallet_id=wallet_id)
user_wallet_ids.remove(wallet_id)
if user_wallet_ids:
return redirect(url_for("core.wallet", usr=g.user.id, wal=user_wallet_ids[0]))
return redirect(url_for("core.home"))
+57
View File
@@ -0,0 +1,57 @@
import requests
from flask import abort, redirect, request, url_for
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore
from lnurl.exceptions import LnurlException # type: ignore
from time import sleep
from lnbits.core import core_app
from lnbits.helpers import Status
from lnbits.settings import WALLET
from ..crud import create_account, get_user, create_wallet, create_payment
@core_app.route("/lnurlwallet")
def lnurlwallet():
memo = "LNbits LNURL funding"
try:
withdraw_res = handle_lnurl(request.args.get("lightning"), response_class=LnurlWithdrawResponse)
except LnurlException:
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
try:
ok, checking_id, payment_request, error_message = WALLET.create_invoice(withdraw_res.max_sats, memo)
except Exception as e:
ok, error_message = False, str(e)
if not ok:
abort(Status.INTERNAL_SERVER_ERROR, error_message)
r = requests.get(
withdraw_res.callback.base,
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
)
if not r.ok:
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
for i in range(10):
invoice_status = WALLET.get_invoice_status(checking_id)
sleep(i)
if not invoice_status.paid:
continue
break
user = get_user(create_account().id)
wallet = create_wallet(user_id=user.id)
create_payment(
wallet_id=wallet.id,
checking_id=checking_id,
amount=withdraw_res.max_sats * 1000,
memo=memo,
pending=invoice_status.pending,
)
return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
-47
View File
@@ -1,47 +0,0 @@
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
email TEXT,
pass TEXT
);
CREATE TABLE IF NOT EXISTS extensions (
user TEXT NOT NULL,
extension TEXT NOT NULL,
active BOOLEAN DEFAULT 0,
UNIQUE (user, extension)
);
CREATE TABLE IF NOT EXISTS wallets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
user TEXT NOT NULL,
adminkey TEXT NOT NULL,
inkey TEXT
);
CREATE TABLE IF NOT EXISTS apipayments (
payhash TEXT NOT NULL,
amount INTEGER NOT NULL,
fee INTEGER NOT NULL DEFAULT 0,
wallet TEXT NOT NULL,
pending BOOLEAN NOT NULL,
memo TEXT,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE (wallet, payhash)
);
CREATE VIEW IF NOT EXISTS balances AS
SELECT wallet, COALESCE(SUM(s), 0) AS balance FROM (
SELECT wallet, SUM(amount) AS s -- incoming
FROM apipayments
WHERE amount > 0 AND pending = 0 -- don't sum pending
GROUP BY wallet
UNION ALL
SELECT wallet, SUM(amount + fee) AS s -- outgoing, sum fees
FROM apipayments
WHERE amount < 0 -- do sum pending
GROUP BY wallet
)
GROUP BY wallet;
+4 -23
View File
@@ -1,8 +1,7 @@
import os
import sqlite3
from .helpers import ExtensionManager
from .settings import LNBITS_PATH, LNBITS_DATA_FOLDER
from .settings import LNBITS_DATA_FOLDER
class Database:
@@ -19,16 +18,16 @@ class Database:
self.cursor.close()
self.connection.close()
def fetchall(self, query: str, values: tuple) -> list:
def fetchall(self, query: str, values: tuple = ()) -> list:
"""Given a query, return cursor.fetchall() rows."""
self.cursor.execute(query, values)
return self.cursor.fetchall()
def fetchone(self, query: str, values: tuple):
def fetchone(self, query: str, values: tuple = ()):
self.cursor.execute(query, values)
return self.cursor.fetchone()
def execute(self, query: str, values: tuple) -> None:
def execute(self, query: str, values: tuple = ()) -> None:
"""Given a query, cursor.execute() it."""
self.cursor.execute(query, values)
self.connection.commit()
@@ -41,21 +40,3 @@ def open_db(db_name: str = "database") -> Database:
def open_ext_db(extension_name: str) -> Database:
return open_db(f"ext_{extension_name}")
def init_databases() -> None:
"""Creates the necessary databases if they don't exist already."""
"""TODO: see how we can deal with migrations."""
schemas = [
("database", os.path.join(LNBITS_PATH, "data", "schema.sql")),
]
for extension in ExtensionManager().extensions:
schemas.append((f"ext_{extension.code}", os.path.join(extension.path, "schema.sql")))
for schema in [s for s in schemas if os.path.exists(s[1])]:
with open_db(schema[0]) as db:
with open(schema[1]) as schemafile:
for stmt in schemafile.read().split(";\n\n"):
db.execute(stmt, [])
+82
View File
@@ -0,0 +1,82 @@
from cerberus import Validator # type: ignore
from flask import g, abort, jsonify, request
from functools import wraps
from typing import List, Union
from uuid import UUID
from lnbits.core.crud import get_user, get_wallet_for_key
from .helpers import Status
def api_check_wallet_key(key_type: str = "invoice"):
def wrap(view):
@wraps(view)
def wrapped_view(**kwargs):
try:
g.wallet = get_wallet_for_key(request.headers["X-Api-Key"], key_type)
except KeyError:
return jsonify({"message": "`X-Api-Key` header missing."}), Status.BAD_REQUEST
if not g.wallet:
return jsonify({"message": "Wrong keys."}), Status.UNAUTHORIZED
return view(**kwargs)
return wrapped_view
return wrap
def api_validate_post_request(*, schema: dict):
def wrap(view):
@wraps(view)
def wrapped_view(**kwargs):
if "application/json" not in request.headers["Content-Type"]:
return jsonify({"message": "Content-Type must be `application/json`."}), Status.BAD_REQUEST
v = Validator(schema)
g.data = {key: request.json[key] for key in schema.keys()}
if not v.validate(g.data):
return jsonify({"message": f"Errors in request data: {v.errors}"}), Status.BAD_REQUEST
return view(**kwargs)
return wrapped_view
return wrap
def check_user_exists(param: str = "usr"):
def wrap(view):
@wraps(view)
def wrapped_view(**kwargs):
g.user = get_user(request.args.get(param, type=str)) or abort(Status.NOT_FOUND, "User not found.")
return view(**kwargs)
return wrapped_view
return wrap
def validate_uuids(params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4):
def wrap(view):
@wraps(view)
def wrapped_view(**kwargs):
query_params = {param: request.args.get(param, type=str) for param in params}
for param, value in query_params.items():
if not value and (required is True or (required and param in required)):
abort(Status.BAD_REQUEST, f"`{param}` is required.")
if value:
try:
UUID(value, version=version)
except ValueError:
abort(Status.BAD_REQUEST, f"`{param}` is not a valid UUID.")
return view(**kwargs)
return wrapped_view
return wrap
+11
View File
@@ -0,0 +1,11 @@
<h1>Example Extension</h1>
<h2>*tagline*</h2>
This is an example extension to help you organise and build you own.
Try to include an image
<img src="https://i.imgur.com/9i4xcQB.png">
<h2>If your extension has API endpoints, include useful ones here</h2>
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
+8
View File
@@ -0,0 +1,8 @@
from flask import Blueprint
amilk_ext: Blueprint = Blueprint("amilk", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
from .views import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "AMilk",
"short_description": "Assistant Faucet Milker",
"icon": "room_service",
"contributors": ["arcbtc"]
}
+44
View File
@@ -0,0 +1,44 @@
from base64 import urlsafe_b64encode
from uuid import uuid4
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from .models import AMilk
def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk:
with open_ext_db("amilk") as db:
amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
db.execute(
"""
INSERT INTO amilks (id, wallet, lnurl, atime, amount)
VALUES (?, ?, ?, ?, ?)
""",
(amilk_id, wallet_id, lnurl, atime, amount),
)
return get_amilk(amilk_id)
def get_amilk(amilk_id: str) -> Optional[AMilk]:
with open_ext_db("amilk") as db:
row = db.fetchone("SELECT * FROM amilks WHERE id = ?", (amilk_id,))
return AMilk(**row) if row else None
def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("amilk") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,))
return [AMilk(**row) for row in rows]
def delete_amilk(amilk_id: str) -> None:
with open_ext_db("amilk") as db:
db.execute("DELETE FROM amilks WHERE id = ?", (amilk_id,))
+21
View File
@@ -0,0 +1,21 @@
from lnbits.db import open_ext_db
def m001_initial(db):
"""
Initial amilks table.
"""
db.execute("""
CREATE TABLE IF NOT EXISTS amilks (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
lnurl TEXT NOT NULL,
atime INTEGER NOT NULL,
amount INTEGER NOT NULL
);
""")
def migrate():
with open_ext_db("amilk") as db:
m001_initial(db)
+9
View File
@@ -0,0 +1,9 @@
from typing import NamedTuple
class AMilk(NamedTuple):
id: str
wallet: str
lnurl: str
atime: int
amount: int
@@ -0,0 +1,16 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">Assistant Faucet Milker</h5>
<p>Milking faucets with software, known as "assmilking", seems at first to be black-hat, although in fact there might be some unexplored use cases. An LNURL withdraw gives someone the right to pull funds, which can be done over time. An LNURL withdraw could be used outside of just faucets, to provide money streaming and repeat payments.<br/>Paste or scan an LNURL withdraw, enter the amount for the AMilk to pull and the frequency for it to be pulled.<br/>
<small> Created by, <a href="https://github.com/benarc">Ben Arc</a></small></p>
</q-card>
</q-card-section>
</q-card-section></q-expansion-item>
@@ -0,0 +1,230 @@
{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="amilkDialog.show = true">New AMilk</q-btn>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">AMilks</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table dense flat
:data="amilks"
row-key="id"
:columns="amilksTable.columns"
:pagination.sync="amilksTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.label }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn flat dense size="xs" @click="deleteAMilk(props.row.id)" icon="cancel" color="pink"></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits Assistant Faucet Milker Extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "amilk/_api_docs.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="amilkDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createAMilk" class="q-gutter-md">
<q-select filled dense emit-value v-model="amilkDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
</q-select>
<q-input filled dense
v-model.trim="amilkDialog.data.lnurl"
type="url"
label="LNURL Withdraw"></q-input>
<q-input filled dense
v-model.number="amilkDialog.data.amount"
type="number"
label="Amount *"></q-input>
<q-input filled dense
v-model.trim="amilkDialog.data.atime"
type="number"
label="Hit frequency (secs)"
placeholder="Frequency to be hit"></q-input>
<q-btn unelevated
color="deep-purple"
:disable="amilkDialog.data.amount == null || amilkDialog.data.amount < 0 || amilkDialog.data.lnurl == null"
type="submit">Create amilk</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %}
{% block scripts %}
{{ window_vars(user) }}
<script>
var mapAMilk = function (obj) {
obj.date = Quasar.utils.date.formatDate(new Date(obj.time * 1000), 'YYYY-MM-DD HH:mm');
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount);
obj.wall = ['/amilk/', obj.id].join('');
return obj;
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
amilks: [],
amilksTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'lnurl', align: 'left', label: 'LNURL', field: 'lnurl'},
{name: 'atime', align: 'left', label: 'Freq', field: 'atime'},
{name: 'amount', align: 'left', label: 'Amount', field: 'amount'}
],
pagination: {
rowsPerPage: 10
}
},
amilkDialog: {
show: false,
data: {}
}
};
},
methods: {
getAMilks: function () {
var self = this;
LNbits.api.request(
'GET',
'/amilk/api/v1/amilk?all_wallets',
this.g.user.wallets[0].inkey
).then(function (response) {
self.amilks = response.data.map(function (obj) {
response.data.forEach(MILK);
function MILK(item){
window.setInterval(function(){
LNbits.api.request(
'GET',
'/amilk/api/v1/amilk/milk/' + item.id,
"Lorem"
).then(function (response) {
self.amilks = response.data.map(function (obj) {
return mapAMilk(obj);
});
});
},
item.atime*1000);
}
return mapAMilk(obj);
});
});
},
createAMilk: function () {
var data = {
lnurl: this.amilkDialog.data.lnurl,
atime: parseInt(this.amilkDialog.data.atime),
amount: this.amilkDialog.data.amount
};
var self = this;
console.log(this.amilkDialog.data.wallet);
LNbits.api.request(
'POST',
'/amilk/api/v1/amilk',
_.findWhere(this.g.user.wallets, {id: this.amilkDialog.data.wallet}).inkey,
data
).then(function (response) {
self.amilks.push(mapAMilk(response.data));
self.amilkDialog.show = false;
self.amilkDialog.data = {};
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
},
deleteAMilk: function (amilkId) {
var self = this;
var amilk = _.findWhere(this.amilks, {id: amilkId});
LNbits.utils.confirmDialog(
'Are you sure you want to delete this AMilk link?'
).onOk(function () {
LNbits.api.request(
'DELETE',
'/amilk/api/v1/amilks/' + amilkId,
_.findWhere(self.g.user.wallets, {id: amilk.wallet}).inkey
).then(function (response) {
self.amilks = _.reject(self.amilks, function (obj) { return obj.id == amilkId; });
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
});
},
exportCSV: function () {
LNbits.utils.exportCSV(this.amilksTable.columns, this.amilks);
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getAMilks();
}
}
});
</script>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
from flask import g, abort, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.amilk import amilk_ext
from lnbits.helpers import Status
from .crud import get_amilk
@amilk_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
return render_template("amilk/index.html", user=g.user)
@amilk_ext.route("/<amilk_id>")
def wall(amilk_id):
amilk = get_amilk(amilk_id) or abort(Status.NOT_FOUND, "AMilk does not exist.")
return render_template("amilk/wall.html", amilk=amilk)
+94
View File
@@ -0,0 +1,94 @@
from flask import g, jsonify, request
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.helpers import Status
from lnbits.extensions.amilk import amilk_ext
from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
from lnbits.core.services import create_invoice
from flask import abort, redirect, request, url_for
from lnurl import LnurlWithdrawResponse, handle as handle_lnurl
from lnurl.exceptions import LnurlException
from time import sleep
import requests
from lnbits.settings import WALLET
@amilk_ext.route("/api/v1/amilk", methods=["GET"])
@api_check_wallet_key("invoice")
def api_amilks():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([amilk._asdict() for amilk in get_amilks(wallet_ids)]), Status.OK
@amilk_ext.route("/api/v1/amilk/milk/<amilk_id>", methods=["GET"])
def api_amilkit(amilk_id):
milk = get_amilk(amilk_id)
memo = milk.id
try:
withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse)
except LnurlException:
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
print(withdraw_res.max_sats)
try:
checking_id, payment_request = create_invoice(wallet_id=milk.wallet, amount=withdraw_res.max_sats, memo=memo)
#print(payment_request)
except Exception as e:
error_message = False, str(e)
r = requests.get(
withdraw_res.callback.base,
params={**withdraw_res.callback.query_params, **{"k1": withdraw_res.k1, "pr": payment_request}},
)
if not r.ok:
abort(Status.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
for i in range(10):
invoice_status = WALLET.get_invoice_status(checking_id)
sleep(i)
if not invoice_status.paid:
continue
else:
return jsonify({"paid": False}), Status.OK
break
return jsonify({"paid": True}), Status.OK
@amilk_ext.route("/api/v1/amilk", methods=["POST"])
@api_check_wallet_key("invoice")
@api_validate_post_request(schema={
"lnurl": {"type": "string", "empty": False, "required": True},
"atime": {"type": "integer", "min": 0, "required": True},
"amount": {"type": "integer", "min": 0, "required": True},
})
def api_amilk_create():
amilk = create_amilk(wallet_id=g.wallet.id, lnurl=g.data["lnurl"], atime=g.data["atime"], amount=g.data["amount"])
return jsonify(amilk._asdict()), Status.CREATED
@amilk_ext.route("/api/v1/amilk/<amilk_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
def api_amilk_delete(amilk_id):
amilk = get_amilk(amilk_id)
if not amilk:
return jsonify({"message": "Paywall does not exist."}), Status.NOT_FOUND
if amilk.wallet != g.wallet.id:
return jsonify({"message": "Not your amilk."}), Status.FORBIDDEN
delete_amilk(amilk_id)
return "", Status.NO_CONTENT
+10
View File
@@ -0,0 +1,10 @@
<h1>Diagon Alley</h1>
<h2>A movable market stand</h2>
Make a list of products to sell, point the list to an indexer (or many), stack sats.
Diagon Alley is a movable market stand, for anon transactions. You then give permission for an indexer to list those products. Delivery addresses are sent through the Lightning Network.
<img src="https://i.imgur.com/P1tvBSG.png">
<h2>API endpoints</h2>
<code>curl -X GET http://YOUR-TOR-ADDRESS</code>
@@ -0,0 +1,8 @@
from flask import Blueprint
diagonalley_ext: Blueprint = Blueprint("diagonalley", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
from .views import * # noqa
@@ -0,0 +1,6 @@
{
"name": "Diagon Alley",
"short_description": "Movable anonymous market stand",
"icon": "add_shopping_cart",
"contributors": ["eillarra"]
}
+185
View File
@@ -0,0 +1,185 @@
from base64 import urlsafe_b64encode
from uuid import uuid4
from typing import List, Optional, Union
import requests
from lnbits.db import open_ext_db
from lnbits.settings import WALLET
from .models import Products, Orders, Indexers
import re
regex = re.compile(
r'^(?:http|ftp)s?://' # http:// or https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|'
r'localhost|'
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
r'(?::\d+)?'
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
###Products
def create_diagonalleys_product(*, wallet_id: str, product: str, categories: str, description: str, image: str, price: int, quantity: int) -> Products:
with open_ext_db("diagonalley") as db:
product_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
db.execute(
"""
INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(product_id, wallet_id, product, categories, description, image, price, quantity),
)
return get_diagonalleys_product(product_id)
def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("diagonalley") as db:
db.execute(f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id))
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
return get_diagonalleys_indexer(product_id)
def get_diagonalleys_product(product_id: str) -> Optional[Products]:
with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
return Products(**row) if row else None
def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Products]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,))
return [Products(**row) for row in rows]
def delete_diagonalleys_product(product_id: str) -> None:
with open_ext_db("diagonalley") as db:
db.execute("DELETE FROM products WHERE id = ?", (product_id,))
###Indexers
def create_diagonalleys_indexer(wallet_id: str, shopname: str, indexeraddress: str, shippingzone1: str, shippingzone2: str, zone1cost: int, zone2cost: int, email: str) -> Indexers:
with open_ext_db("diagonalley") as db:
indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
db.execute(
"""
INSERT INTO indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(indexer_id, wallet_id, shopname, indexeraddress, False, 0, shippingzone1, shippingzone2, zone1cost, zone2cost, email),
)
return get_diagonalleys_indexer(indexer_id)
def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("diagonalley") as db:
db.execute(f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id))
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
return get_diagonalleys_indexer(indexer_id)
def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
with open_ext_db("diagonalley") as db:
roww = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
try:
x = requests.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
if x.status_code == 200:
print(x)
print("poo")
with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (True, indexer_id,))
else:
with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (False, indexer_id,))
except:
print("An exception occurred")
with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
return Indexers(**row) if row else None
def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexers]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
for r in rows:
try:
x = requests.get(r["indexeraddress"] + "/" + r["ratingkey"])
if x.status_code == 200:
with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (True, r["id"],))
else:
with open_ext_db("diagonalley") as db:
db.execute("UPDATE indexers SET online = ? WHERE id = ?", (False, r["id"],))
except:
print("An exception occurred")
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,))
return [Indexers(**row) for row in rows]
def delete_diagonalleys_indexer(indexer_id: str) -> None:
with open_ext_db("diagonalley") as db:
db.execute("DELETE FROM indexers WHERE id = ?", (indexer_id,))
###Orders
def create_diagonalleys_order(*, productid: str, wallet: str, product: str, quantity: int, shippingzone: str, address: str, email: str, invoiceid: str, paid: bool, shipped: bool) -> Indexers:
with open_ext_db("diagonalley") as db:
order_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
db.execute(
"""
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(order_id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, False, False),
)
return get_diagonalleys_order(order_id)
def get_diagonalleys_order(order_id: str) -> Optional[Orders]:
with open_ext_db("diagonalley") as db:
row = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
return Orders(**row) if row else None
def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("diagonalley") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
for r in rows:
PAID = WALLET.get_invoice_status(r["invoiceid"]).paid
if PAID:
with open_ext_db("diagonalley") as db:
db.execute("UPDATE orders SET paid = ? WHERE id = ?", (True, r["id"],))
rows = db.fetchall(f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,))
return [Orders(**row) for row in rows]
def delete_diagonalleys_order(order_id: str) -> None:
with open_ext_db("diagonalley") as db:
db.execute("DELETE FROM orders WHERE id = ?", (order_id,))
@@ -0,0 +1,64 @@
from lnbits.db import open_ext_db
def m001_initial(db):
"""
Initial products table.
"""
db.execute("""
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
product TEXT NOT NULL,
categories TEXT NOT NULL,
description TEXT NOT NULL,
image TEXT NOT NULL,
price INTEGER NOT NULL,
quantity INTEGER NOT NULL
);
""")
"""
Initial indexers table.
"""
db.execute("""
CREATE TABLE IF NOT EXISTS indexers (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
shopname TEXT NOT NULL,
indexeraddress TEXT NOT NULL,
online BOOLEAN NOT NULL,
rating INTEGER NOT NULL,
shippingzone1 TEXT NOT NULL,
shippingzone2 TEXT NOT NULL,
zone1cost INTEGER NOT NULL,
zone2cost INTEGER NOT NULL,
email TEXT NOT NULL
);
""")
"""
Initial orders table.
"""
db.execute("""
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
productid TEXT NOT NULL,
wallet TEXT NOT NULL,
product TEXT NOT NULL,
quantity INTEGER NOT NULL,
shippingzone INTEGER NOT NULL,
address TEXT NOT NULL,
email TEXT NOT NULL,
invoiceid TEXT NOT NULL,
paid BOOLEAN NOT NULL,
shipped BOOLEAN NOT NULL
);
""")
def migrate():
with open_ext_db("diagonalley") as db:
m001_initial(db)
+39
View File
@@ -0,0 +1,39 @@
from typing import NamedTuple
class Indexers(NamedTuple):
id: str
wallet: str
shopname: str
indexeraddress: str
online: bool
rating: str
shippingzone1: str
shippingzone2: str
zone1cost: int
zone2cost: int
email: str
class Products(NamedTuple):
id: str
wallet: str
product: str
categories: str
description: str
image: str
price: int
quantity: int
class Orders(NamedTuple):
id: str
productid: str
wallet: str
product: str
quantity: int
shippingzone: int
address: str
email: str
invoiceid: str
paid: bool
shipped: bool
@@ -0,0 +1,64 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Info"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">Diagon Alley: Movable, anonymous market stall</h5>
<p>Make a list of products to sell, point your list of products at a public indexer. Buyers browse your products on the indexer, and pay you directly. Ratings are managed by the indexer. Your stall can be listed in multiple indexers, even over TOR, if you wish to be anonymous.<br/>
<small> Created by, <a href="https://github.com/benarc">Ben Arc</a></small></p>
</q-card>
</q-card-section>
</q-card-section>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="Get prodcuts, categorised by wallet">
<q-card>
<q-card-section>
<code><span class="text-light-blue">GET</span> /api/v1/diagonalley/stall/products/&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">Returns 201 CREATED (application/json)</h5>
<code>Product JSON list</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X GET {{ request.url_root }}diagonalley/api/v1/diagonalley/stall/products/&lt;indexer_id&gt;</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get invoice for product">
<q-card>
<q-card-section>
<code><span class="text-light-green">POST</span> /api/v1/diagonalley/stall/order/&lt;indexer_id&gt;</code>
<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>
<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>
<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>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Check a product has been shipped"
class="q-mb-md">
<q-card>
<q-card-section>
<code><span class="text-light-blue">GET</span> /diagonalley/api/v1/diagonalley/stall/checkshipped/&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">Returns 200 OK (application/json)</h5>
<code>{"shipped": &lt;boolean&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code>curl -X GET {{ request.url_root }}diagonalley/api/v1/diagonalley/stall/checkshipped/&lt;checking_id&gt; -H "Content-type: application/json"</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
@@ -0,0 +1,660 @@
{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="productDialog.show = true">New Product</q-btn>
<q-btn unelevated color="deep-purple" @click="indexerDialog.show = true">New Indexer
<q-tooltip>
Frontend shop your stall will list its products in
</q-tooltip></q-btn>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Products</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportProductsCSV">Export to CSV</q-btn>
</div>
</div>
<q-table dense flat
:data="products"
row-key="id"
:columns="productsTable.columns"
:pagination.sync="productsTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn unelevated dense size="xs" icon="add_shopping_cart" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" type="a" :href="props.row.wallet" target="_blank"></q-btn>
<q-tooltip>
Link to pass to stall indexer
</q-tooltip>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn flat dense size="xs" @click="openProductUpdateDialog(props.row.id)" icon="edit" color="light-blue"></q-btn>
<q-btn flat dense size="xs" @click="deleteProduct(props.row.id)" icon="cancel" color="pink"></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Indexers</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportIndexersCSV">Export to CSV</q-btn>
</div>
</div>
<q-table dense flat
:data="indexers"
row-key="id"
:columns="indexersTable.columns"
:pagination.sync="indexersTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn flat dense size="xs" @click="openIndexerUpdateDialog(props.row.id)" icon="edit" color="light-blue"></q-btn>
<q-btn flat dense size="xs" @click="deleteIndexer(props.row.id)" icon="cancel" color="pink"></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Orders</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportOrdersCSV">Export to CSV</q-btn>
</div>
</div>
<q-table dense flat
:data="orders"
row-key="id"
:columns="ordersTable.columns"
:pagination.sync="ordersTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn flat dense size="xs" @click="shipOrder(props.row.id)" icon="add_shopping_cart" color="green">
<q-tooltip>
Product shipped?
</q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn flat dense size="xs" @click="deleteOrder(props.row.id)" icon="cancel" color="pink"></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">LNbits Diagon Alley Extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "diagonalley/_api_docs.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="productDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-select filled dense emit-value v-model="productDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
</q-select>
<q-input filled dense
v-model.trim="productDialog.data.product"
label="Product"></q-input>
<q-input filled dense
v-model.trim="productDialog.data.categories"
placeholder="cakes, guns, wool, drugs"
label="Categories seperated by comma"></q-input>
<q-input filled dense
v-model.trim="productDialog.data.description"
label="Description"></q-input>
<q-input filled dense
v-model.trim="productDialog.data.image"
label="Image" placeholder="Imagur link (max 500/500px)"></q-input>
<q-input filled dense
v-model.number="productDialog.data.price"
type="number"
label="Price"></q-input>
<q-input filled dense
v-model.number="productDialog.data.quantity"
type="number"
label="Quantity"
></q-input>
<div class="row q-mt-lg">
<q-btn v-if="productDialog.data.id" unelevated
color="deep-purple"
type="submit">Update Product</q-btn>
<q-btn v-else unelevated
color="deep-purple"
:disable="productDialog.data.image == null
|| productDialog.data.product == null
|| productDialog.data.description == null
|| productDialog.data.quantity == null"
type="submit">Create Product</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="indexerDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendIndexerFormData" class="q-gutter-md">
<q-select filled dense emit-value v-model="indexerDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
</q-select>
<q-input filled dense
v-model.trim="indexerDialog.data.shopname"
label="Shop Name"></q-input>
<q-input filled dense
v-model.trim="indexerDialog.data.indexeraddress"
label="Shop link (LNbits will point to)"></q-input>
<q-select
filled dense
v-model.trim="indexerDialog.data.shippingzone1"
multiple
:options="shippingoptions"
label="Shipping Zone 1"
></q-select>
<q-input filled dense
v-model.number="indexerDialog.data.zone1cost"
type="number"
label="Zone 1 Cost"></q-input>
<q-select
filled dense
v-model.trim="indexerDialog.data.shippingzone2"
multiple
:options="shippingoptions"
label="Shipping Zone 2"
></q-select>
<q-input filled dense
v-model.number="indexerDialog.data.zone2cost"
type="number"
label="Zone 2 Cost"></q-input>
<q-input filled dense
v-model.trim="indexerDialog.data.email"
label="Email to share with customers"></q-input>
<div class="row q-mt-lg">
<q-btn v-if="indexerDialog.data.id" unelevated
color="deep-purple"
type="submit">Update Indexer</q-btn>
<q-btn v-else unelevated
color="deep-purple"
:disable="indexerDialog.data.shopname == null
|| indexerDialog.data.shippingzone1 == null
|| indexerDialog.data.indexeraddress == null
|| indexerDialog.data.zone1cost == null
|| indexerDialog.data.shippingzone2 == null
|| indexerDialog.data.zone2cost == null
|| indexerDialog.data.email == null"
type="submit">Create Indexer</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %}
{% block scripts %}
{{ window_vars(user) }}
<script>
var mapDiagonAlley = function (obj) {
obj.date = Quasar.utils.date.formatDate(new Date(obj.time * 1000), 'YYYY-MM-DD HH:mm');
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount);
obj.wall = ['/diagonalley/', obj.id].join('');
obj._data = _.clone(obj);
return obj;
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
products: [],
orders: [],
indexers: [],
shippedModel: false,
shippingoptions: [
'Australia', 'Austria', 'Belgium', 'Brazil', 'Canada', 'Denmark', 'Finland', 'France*', 'Germany', 'Greece', 'Hong Kong', 'Hungary', 'Ireland', 'Indonesia', 'Israel', 'Italy', 'Japan', 'Kazakhstan', 'Korea', 'Luxembourg', 'Malaysia', 'Mexico', 'Netherlands', 'New Zealand', 'Norway', 'Poland', 'Portugal', 'Russia', 'Saudi Arabia', 'Singapore', 'Spain', 'Sweden', 'Switzerland', 'Thailand', 'Turkey', 'Ukraine', 'United Kingdom**', 'United States***', 'Vietnam', 'China'
],
label: '',
indexersTable: {
columns: [
{name: 'shopname', align: 'left', label: 'Shop', field: 'shopname'},
{name: 'indexeraddress', align: 'left', label: 'Address', field: 'indexeraddress'},
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'rating', align: 'left', label: 'Your Rating', field: 'rating'},
{name: 'email', align: 'left', label: 'Your email', field: 'email'},
{name: 'online', align: 'left', label: 'Online', field: 'online'}
],
pagination: {
rowsPerPage: 10
}
},
ordersTable: {
columns: [
{name: 'product', align: 'left', label: 'Product', field: 'product'},
{name: 'quantity', align: 'left', label: 'Quantity', field: 'quantity'},
{name: 'address', align: 'left', label: 'Address', field: 'address'},
{name: 'invoiceid', align: 'left', label: 'InvoiceID', field: 'invoiceid'},
{name: 'paid', align: 'left', label: 'Paid', field: 'paid'},
{name: 'shipped', align: 'left', label: 'Shipped', field: 'shipped'}
],
pagination: {
rowsPerPage: 10
}
},
productsTable: {
columns: [
{name: 'product', align: 'left', label: 'Product', field: 'product'},
{name: 'description', align: 'left', label: 'Description', field: 'description'},
{name: 'categories', align: 'left', label: 'Categories', field: 'categories'},
{name: 'image', align: 'left', label: 'Image', field: 'image'},
{name: 'price', align: 'left', label: 'Price', field: 'price'},
{name: 'quantity', align: 'left', label: 'Quantity', field: 'quantity'},
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'}
],
pagination: {
rowsPerPage: 10
}
},
productDialog: {
show: false,
data: {}
},
orderDialog: {
show: false,
data: {}
},
indexerDialog: {
show: false,
data: {}
},
};
},
methods: {
getIndexers: function () {
var self = this;
LNbits.api.request(
'GET',
'/diagonalley/api/v1/diagonalley/indexers?all_wallets',
this.g.user.wallets[0].inkey
).then(function (response) {
self.indexers = response.data.map(function (obj) {
return mapDiagonAlley(obj);
});
});
},
openIndexerUpdateDialog: function (linkId) {
var link = _.findWhere(this.indexers, {id: linkId});
this.indexerDialog.data = _.clone(link._data);
this.indexerDialog.show = true;
},
sendIndexerFormData: function () {
if (this.indexerDialog.data.id){}
else{
var data = {
shopname: this.indexerDialog.data.shopname,
indexeraddress: this.indexerDialog.data.indexeraddress,
shippingzone1: this.indexerDialog.data.shippingzone1.join(', '),
zone1cost: this.indexerDialog.data.zone1cost,
shippingzone2: this.indexerDialog.data.shippingzone2.join(', '),
zone2cost: this.indexerDialog.data.zone2cost,
email: this.indexerDialog.data.email
};}
if (this.indexerDialog.data.id) { this.updateIndexer(this.indexerDialog.data); }
else { this.createIndexer(data); }
},
updateIndexer: function (data) {
var self = this;
LNbits.api.request(
'PUT',
'/diagonalley/api/v1/diagonalley/indexers' + data.id,
_.findWhere(this.g.user.wallets, {id: this.indexerDialog.data.wallet}).inkey,
_.pick(data, 'shopname', 'indexeraddress', 'shippingzone1', 'zone1cost', 'shippingzone2', 'zone2cost', 'email')
).then(function (response) {
self.indexers = _.reject(self.indexers, function (obj) { return obj.id == data.id; });
self.indexers.push(mapDiagonAlley(response.data));
self.indexerDialog.show = false;
self.indexerDialog.data = {};
data = {};
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
},
createIndexer: function (data) {
var self = this;
LNbits.api.request(
'POST',
'/diagonalley/api/v1/diagonalley/indexers',
_.findWhere(this.g.user.wallets, {id: this.indexerDialog.data.wallet}).inkey,
data
).then(function (response) {
self.indexers.push(mapDiagonAlley(response.data));
self.indexerDialog.show = false;
self.indexerDialog.data = {};
data = {};
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
},
deleteIndexer: function (indexerId) {
var self = this;
var indexer = _.findWhere(this.indexers, {id: indexerId});
LNbits.utils.confirmDialog(
'Are you sure you want to delete this Indexer link?'
).onOk(function () {
LNbits.api.request(
'DELETE',
'/diagonalley/api/v1/diagonalley/indexers/' + indexerId,
_.findWhere(self.g.user.wallets, {id: indexer.wallet}).inkey
).then(function (response) {
self.indexers = _.reject(self.indexers, function (obj) { return obj.id == indexerId; });
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
});
},
exportIndexersCSV: function () {
LNbits.utils.exportCSV(this.indexersTable.columns, this.indexers);
},
getOrders: function () {
var self = this;
LNbits.api.request(
'GET',
'/diagonalley/api/v1/diagonalley/orders?all_wallets',
this.g.user.wallets[0].inkey
).then(function (response) {
self.orders = response.data.map(function (obj) {
return mapDiagonAlley(obj);
});
});
},
createOrder: function () {
var data = {
address: this.orderDialog.data.address,
email: this.orderDialog.data.email,
quantity: this.orderDialog.data.quantity,
shippingzone: this.orderDialog.data.shippingzone
};
var self = this;
LNbits.api.request(
'POST',
'/diagonalley/api/v1/diagonalley/orders',
_.findWhere(this.g.user.wallets, {id: this.orderDialog.data.wallet}).inkey,
data
).then(function (response) {
self.orders.push(mapDiagonAlley(response.data));
self.orderDialog.show = false;
self.orderDialog.data = {};
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
},
deleteOrder: function (orderId) {
var self = this;
var order = _.findWhere(this.orders, {id: orderId});
LNbits.utils.confirmDialog(
'Are you sure you want to delete this order link?'
).onOk(function () {
LNbits.api.request(
'DELETE',
'/diagonalley/api/v1/diagonalley/orders/' + orderId,
_.findWhere(self.g.user.wallets, {id: order.wallet}).inkey
).then(function (response) {
self.orders = _.reject(self.orders, function (obj) { return obj.id == orderId; });
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
});
},
shipOrder: function (order_id){
var self = this;
LNbits.api.request(
'GET',
'/diagonalley/api/v1/diagonalley/orders/shipped/' + order_id,
this.g.user.wallets[0].inkey
).then(function (response) {
self.orders = response.data.map(function (obj) {
return mapDiagonAlley(obj);
});
});
},
exportOrdersCSV: function () {
LNbits.utils.exportCSV(this.ordersTable.columns, this.orders);
},
getProducts: function () {
var self = this;
LNbits.api.request(
'GET',
'/diagonalley/api/v1/diagonalley/products?all_wallets',
this.g.user.wallets[0].inkey
).then(function (response) {
self.products = response.data.map(function (obj) {
return mapDiagonAlley(obj);
});
});
},
openProductUpdateDialog: function (linkId) {
var link = _.findWhere(this.products, {id: linkId});
this.productDialog.data = _.clone(link._data);
this.productDialog.show = true;
},
sendProductFormData: function () {
if (this.productDialog.data.id){}
else{
var data = {
product: this.productDialog.data.product,
categories: this.productDialog.data.categories,
description: this.productDialog.data.description,
image: this.productDialog.data.image,
price: this.productDialog.data.price,
quantity: this.productDialog.data.quantity
};}
if (this.productDialog.data.id) {
this.updateProduct(this.productDialog.data); }
else { this.createProduct(data); }
},
updateProduct: function (data) {
var self = this;
LNbits.api.request(
'PUT',
'/diagonalley/api/v1/diagonalley/products' + data.id,
_.findWhere(this.g.user.wallets, {id: this.productDialog.data.wallet}).inkey,
_.pick(data, 'shopname', 'indexeraddress', 'shippingzone1', 'zone1cost', 'shippingzone2', 'zone2cost', 'email')
).then(function (response) {
self.products = _.reject(self.products, function (obj) { return obj.id == data.id; });
self.products.push(mapDiagonAlley(response.data));
self.productDialog.show = false;
self.productDialog.data = {};
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
},
createProduct: function (data) {
var self = this;
LNbits.api.request(
'POST',
'/diagonalley/api/v1/diagonalley/products',
_.findWhere(this.g.user.wallets, {id: this.productDialog.data.wallet}).inkey,
data
).then(function (response) {
self.products.push(mapDiagonAlley(response.data));
self.productDialog.show = false;
self.productDialog.data = {};
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
},
deleteProduct: function (productId) {
var self = this;
var product = _.findWhere(this.products, {id: productId});
LNbits.utils.confirmDialog(
'Are you sure you want to delete this products link?'
).onOk(function () {
LNbits.api.request(
'DELETE',
'/diagonalley/api/v1/diagonalley/products/' + productId,
_.findWhere(self.g.user.wallets, {id: product.wallet}).inkey
).then(function (response) {
self.products = _.reject(self.products, function (obj) { return obj.id == productId; });
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
});
},
exportProductsCSV: function () {
LNbits.utils.exportCSV(this.productsTable.columns, this.products);
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getProducts();
this.getOrders();
this.getIndexers();
}
}
});
</script>
{% endblock %}
@@ -0,0 +1 @@
<script>console.log("{{ stall }}")</script>
+14
View File
@@ -0,0 +1,14 @@
from flask import g, abort, render_template, jsonify
import json
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.diagonalley import diagonalley_ext
from lnbits.helpers import Status
from lnbits.db import open_ext_db
@diagonalley_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
return render_template("diagonalley/index.html", user=g.user)
+251
View File
@@ -0,0 +1,251 @@
from flask import g, jsonify, request
from lnbits.core.crud import get_user
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.helpers import Status
from lnbits.extensions.diagonalley import diagonalley_ext
from .crud import create_diagonalleys_product,get_diagonalleys_product,get_diagonalleys_products,delete_diagonalleys_product,create_diagonalleys_indexer,update_diagonalleys_indexer,get_diagonalleys_indexer,get_diagonalleys_indexers,delete_diagonalleys_indexer,create_diagonalleys_order,get_diagonalleys_order,get_diagonalleys_orders,delete_diagonalleys_order
from lnbits.core.services import create_invoice
from base64 import urlsafe_b64encode
from uuid import uuid4
from lnbits.db import open_ext_db
###Products
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_products():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([product._asdict() for product in get_diagonalleys_products(wallet_ids)]), Status.OK
@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"])
@diagonalley_ext.route("/api/v1/diagonalley/products<product_id>", methods=["PUT"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(schema={
"product": {"type": "string", "empty": False, "required": True},
"categories": {"type": "string", "empty": False, "required": True},
"description": {"type": "string", "empty": False, "required": True},
"image": {"type": "string", "empty": False, "required": True},
"price": {"type": "integer", "min": 0, "required": True},
"quantity": {"type": "integer", "min": 0, "required": True}
})
def api_diagonalley_product_create(product_id=None):
if product_id:
product = get_diagonalleys_indexer(product_id)
if not product:
return jsonify({"message": "Withdraw product does not exist."}), Status.NOT_FOUND
if product.wallet != g.wallet.id:
return jsonify({"message": "Not your withdraw product."}), Status.FORBIDDEN
product = update_diagonalleys_product(product_id, **g.data)
else:
product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data)
return jsonify(product._asdict()), Status.OK if product_id else Status.CREATED
@diagonalley_ext.route("/api/v1/diagonalley/products/<product_id>", methods=["DELETE"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_products_delete(product_id):
product = get_diagonalleys_product(product_id)
if not product:
return jsonify({"message": "Product does not exist."}), Status.NOT_FOUND
if product.wallet != g.wallet.id:
return jsonify({"message": "Not your Diagon Alley."}), Status.FORBIDDEN
delete_diagonalleys_product(product_id)
return "", Status.NO_CONTENT
###Indexers
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_indexers():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]), Status.OK
@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"])
@diagonalley_ext.route("/api/v1/diagonalley/indexers<indexer_id>", methods=["PUT"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(schema={
"shopname": {"type": "string", "empty": False, "required": True},
"indexeraddress": {"type": "string", "empty": False, "required": True},
"shippingzone1": {"type": "string", "empty": False, "required": True},
"shippingzone2": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"zone1cost": {"type": "integer", "min": 0, "required": True},
"zone2cost": {"type": "integer", "min": 0, "required": True}
})
def api_diagonalley_indexer_create(indexer_id=None):
if indexer_id:
indexer = get_diagonalleys_indexer(indexer_id)
if not indexer:
return jsonify({"message": "Withdraw indexer does not exist."}), Status.NOT_FOUND
if indexer.wallet != g.wallet.id:
return jsonify({"message": "Not your withdraw indexer."}), Status.FORBIDDEN
indexer = update_diagonalleys_indexer(indexer_id, **g.data)
else:
indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data)
return jsonify(indexer._asdict()), Status.OK if indexer_id else Status.CREATED
@diagonalley_ext.route("/api/v1/diagonalley/indexers/<indexer_id>", methods=["DELETE"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_indexer_delete(indexer_id):
indexer = get_diagonalleys_indexer(indexer_id)
if not indexer:
return jsonify({"message": "Indexer does not exist."}), Status.NOT_FOUND
if indexer.wallet != g.wallet.id:
return jsonify({"message": "Not your Indexer."}), Status.FORBIDDEN
delete_diagonalleys_indexer(indexer_id)
return "", Status.NO_CONTENT
###Orders
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_orders():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]), Status.OK
@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"])
@api_check_wallet_key(key_type="invoice")
@api_validate_post_request(schema={
"id": {"type": "string", "empty": False, "required": True},
"address": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"quantity": {"type": "integer", "empty": False, "required": True},
"shippingzone": {"type": "integer", "empty": False, "required": True},
})
def api_diagonalley_order_create():
order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data)
return jsonify(order._asdict()), Status.CREATED
@diagonalley_ext.route("/api/v1/diagonalley/orders/<order_id>", methods=["DELETE"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalley_order_delete(order_id):
order = get_diagonalleys_order(order_id)
if not order:
return jsonify({"message": "Indexer does not exist."}), Status.NOT_FOUND
if order.wallet != g.wallet.id:
return jsonify({"message": "Not your Indexer."}), Status.FORBIDDEN
delete_diagonalleys_indexer(order_id)
return "", Status.NO_CONTENT
@diagonalley_ext.route("/api/v1/diagonalley/orders/paid/<order_id>", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalleys_order_paid(order_id):
with open_ext_db("diagonalley") as db:
db.execute("UPDATE orders SET paid = ? WHERE id = ?", (True, order_id,))
return "", Status.OK
@diagonalley_ext.route("/api/v1/diagonalley/orders/shipped/<order_id>", methods=["GET"])
@api_check_wallet_key(key_type="invoice")
def api_diagonalleys_order_shipped(order_id):
with open_ext_db("diagonalley") as db:
db.execute("UPDATE orders SET shipped = ? WHERE id = ?", (True, order_id,))
order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
return jsonify([order._asdict() for order in get_diagonalleys_orders(order["wallet"])]), Status.OK
###List products based on indexer id
@diagonalley_ext.route("/api/v1/diagonalley/stall/products/<indexer_id>", methods=["GET"])
def api_diagonalleys_stall_products(indexer_id):
with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
print(rows[1])
if not rows:
return jsonify({"message": "Indexer does not exist."}), Status.NOT_FOUND
products = db.fetchone("SELECT * FROM products WHERE wallet = ?", (rows[1],))
if not products:
return jsonify({"message": "No products"}), Status.NOT_FOUND
return jsonify([products._asdict() for products in get_diagonalleys_products(rows[1])]), Status.OK
###Check a product has been shipped
@diagonalley_ext.route("/api/v1/diagonalley/stall/checkshipped/<checking_id>", methods=["GET"])
def api_diagonalleys_stall_checkshipped(checking_id):
with open_ext_db("diagonalley") as db:
rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,))
return jsonify({"shipped": rows["shipped"]}), Status.OK
###Place order
@diagonalley_ext.route("/api/v1/diagonalley/stall/order/<indexer_id>", methods=["POST"])
@api_validate_post_request(schema={
"id": {"type": "string", "empty": False, "required": True},
"email": {"type": "string", "empty": False, "required": True},
"address": {"type": "string", "empty": False, "required": True},
"quantity": {"type": "integer", "empty": False, "required": True},
"shippingzone": {"type": "integer", "empty": False, "required": True},
})
def api_diagonalley_stall_order(indexer_id):
product = get_diagonalleys_product(g.data["id"])
shipping = get_diagonalleys_indexer(indexer_id)
if g.data["shippingzone"] == 1:
shippingcost = shipping.zone1cost
else:
shippingcost = shipping.zone2cost
checking_id, payment_request = create_invoice(wallet_id=product.wallet, amount=shippingcost + (g.data["quantity"] * product.price), memo=g.data["id"])
selling_id = urlsafe_b64encode(uuid4().bytes_le).decode('utf-8')
with open_ext_db("diagonalley") as db:
db.execute(
"""
INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(selling_id ,g.data["id"] , product.wallet, product.product, g.data["quantity"], g.data["shippingzone"], g.data["address"], g.data["email"], checking_id, False, False),
)
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), Status.OK
+1 -1
View File
@@ -1,7 +1,7 @@
from flask import Blueprint
events_ext = Blueprint("events", __name__, static_folder="static", template_folder="templates")
events_ext: Blueprint = Blueprint("events", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
-5
View File
@@ -1,5 +0,0 @@
{
"name": "LNEVENTS",
"short_description": "LN tickets for events",
"ion_icon": "calendar"
}
@@ -0,0 +1,6 @@
{
"name": "Events",
"short_description": "LN tickets for events.",
"icon": "local_activity",
"contributors": ["arcbtc"]
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Events",
"short_description": "LN tickets for events.",
"icon": "local_activity",
"contributors": ["arcbtc"]
}
+5
View File
@@ -0,0 +1,5 @@
from lnbits.db import open_ext_db
def migrate():
print("pending")
@@ -455,7 +455,7 @@ background-color:
<div id="theform">
<br/><br/><br/>
<center>
<form role="form">
<div class="form-group" style="width:300px;">
@@ -497,7 +497,7 @@ function postAjax(url, data, thekey, success) {
success(xhr.responseText)
}
}
xhr.setRequestHeader('Grpc-Metadata-macaroon', thekey)
xhr.setRequestHeader('X-Api-Key', thekey)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send(params)
return xhr
@@ -513,7 +513,7 @@ function postAjax(url, data, thekey, success) {
success(xhr.responseText)
}
}
xhr.setRequestHeader('Grpc-Metadata-macaroon', thekey)
xhr.setRequestHeader('X-Api-Key', thekey)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send()
@@ -530,7 +530,7 @@ function submitforticket(){
JSON.stringify({"unireg": "{{wave }}", "name": nam}),
"filla",
function(data) {
function(data) {
theinvoice = JSON.parse(data).pay_req
thehash = JSON.parse(data).payment_hash
@@ -549,7 +549,7 @@ function submitforticket(){
document.getElementById('qrcodetxt').innerHTML = theinvoice + "<br/><br/>"
var refreshId = setInterval(function(){
getAjax('/api/v1/invoice/' + thehash, "{{wave}}", function(datab) {
@@ -559,7 +559,7 @@ function submitforticket(){
clearInterval(refreshId)
}
})}, 3000);
})
}
@@ -1,6 +1,6 @@
<!-- @format -->
{% extends "base.html" %} {% block messages %}
{% extends "legacy.html" %} {% block messages %}
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-bell-o"></i>
@@ -42,7 +42,7 @@
{% endif %}
{% endfor %}
<li>
<a href="{{ url_for('extensions') }}?usr={{ user }}">Manager</a></li>
<a href="{{ url_for('core.extensions') }}?usr={{ user }}">Manager</a></li>
</ul>
</li>
{% endblock %}
@@ -63,7 +63,7 @@
<a href="{{ url_for('wallet') }}?usr={{ user }}"><i class="fa fa-dashboard"></i> Home</a>
</li>
<li>
<a href="{{ url_for('extensions') }}?usr={{ user }}"><li class="fa fa-dashboard">Extensions</li></a>
<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>
@@ -220,13 +220,13 @@
$('#datepicker').datepicker({
autoclose: true
})
window.user = {{ user | megajson | safe }}
window.user_wallets = {{ user_wallets | megajson | safe }}
window.user_ext = {{ user_ext | megajson | safe }}
window.user_ev = {{ user_ev | megajson | safe }}
window.user = {{ user | tojson | safe }}
window.user_wallets = {{ user_wallets | tojson | safe }}
window.user_ext = {{ user_ext | tojson | safe }}
window.user_ev = {{ user_ev | tojson | safe }}
const user_ev = window.user_ev
console.log(user_ev)
@@ -453,7 +453,7 @@ background-color:
<!-- Main content -->
<section class="content"><br/><br/>
<center><h1 style="font-size:500%">{{ user_ev[0][6] }}</h1></center>
<br/><br/><br/>
@@ -474,13 +474,13 @@ background-color:
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">
@@ -525,8 +525,8 @@ button:focus .ema {
<script>
window.user_ev = {{ user_ev | megajson | safe }}
window.user_ev_sold = {{ user_ev_sold | megajson | safe }}
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)
@@ -578,7 +578,7 @@ function postAjax(url, data, thekey, success) {
success(xhr.responseText)
}
}
xhr.setRequestHeader('Grpc-Metadata-macaroon', thekey)
xhr.setRequestHeader('X-Api-Key', thekey)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send(params)
return xhr
@@ -594,7 +594,7 @@ function postAjax(url, data, thekey, success) {
success(xhr.responseText)
}
}
xhr.setRequestHeader('Grpc-Metadata-macaroon', thekey)
xhr.setRequestHeader('X-Api-Key', thekey)
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.send()
@@ -666,7 +666,7 @@ function scanQRsend() {
}
function cancelsend() {
location.reload();
location.reload();
}
+1 -1
View File
@@ -21,7 +21,7 @@ def api_getticket():
user_ev = events_ext_db.fetchall("SELECT * FROM events WHERE unireg = ?", (unireg,))
header = {"Content-Type": "application/json", "Grpc-Metadata-macaroon": user_ev[0][4]}
header = {"Content-Type": "application/json", "X-Api-Key": user_ev[0][4]}
data = {"value": str(user_ev[0][10]), "memo": user_ev[0][6]}
print(url_for("api_invoices", _external=True))
r = requests.post(url=url_for("api_invoices", _external=True), headers=header, data=json.dumps(data))
+1 -1
View File
@@ -8,4 +8,4 @@ Try to include an image
<h2>If your extension has API endpoints, include useful ones here</h2>
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "Grpc-Metadata-macaroon: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
+1 -1
View File
@@ -1,7 +1,7 @@
from flask import Blueprint
example_ext = Blueprint("example", __name__, static_folder="static", template_folder="templates")
example_ext: Blueprint = Blueprint("example", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Build your own!",
"short_description": "Join us, make an extension",
"icon": "info",
"contributors": ["github_username"]
}
@@ -1,5 +0,0 @@
{
"name": "SHORT-NAME-FOR-EXTENSIONS-PAGE",
"short_description": "BLah blah blah.",
"ion_icon": "calendar"
}
+5
View File
@@ -0,0 +1,5 @@
from lnbits.db import open_ext_db
def migrate():
print("pending")
-7
View File
@@ -1,7 +0,0 @@
/* create your extensions table and the variables needed here */
CREATE TABLE IF NOT EXISTS example (
key INTEGER PRIMARY KEY AUTOINCREMENT,
usr TEXT,
wal TEXT,
walnme TEXT
);
@@ -1,103 +1,54 @@
<!-- @format -->
{% extends "base.html" %}
{% extends "base.html" %} {% block messages %}
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-bell-o"></i>
<span class="label label-danger">!</span>
</a>
<ul class="dropdown-menu">
<li class="header"><b>Instant wallet, bookmark to save</b></li>
<li></li>
</ul>
{% endblock %}
{% from "macros.jinja" import window_vars with context %}
{% block menuitems %}
<li class="treeview">
<a href="#">
<i class="fa fa-bitcoin"></i> <span>Wallets</span>
<i class="fa fa-angle-left pull-right"></i>
</a>
<ul class="treeview-menu">
{% for w in user_wallets %}
<li>
<a href="{{ url_for('wallet') }}?wal={{ w.id }}&usr={{ w.user }}"><i class="fa fa-bolt"></i> {{ w.name }}</a>
</li>
{% endfor %}
<li><a onclick="sidebarmake()">Add a wallet +</a></li>
<div id="sidebarmake"></div>
</ul>
</li>
<li class="active treeview">
<a href="#">
<i class="fa fa-th"></i> <span>Extensions</span>
<i class="fa fa-angle-left pull-right"></i>
</a>
<ul class="treeview-menu">
{% for extension in EXTENSIONS %}
{% if extension.code in user_ext %}
<li>
<a href="{{ url_for(extension.code + '.index') }}?usr={{ user }}"><i class="fa fa-plus"></i> {{ extension.name }}</a>
</li>
{% endif %}
{% endfor %}
<li>
<a href="{{ url_for('extensions') }}?usr={{ user }}">Manager</a></li>
</ul>
</li>
{% block page %}
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-mt-none q-mb-md">Frameworks used by LNbits</h5>
<q-list>
<q-item 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-label>{{ tool.name }}</q-item-label>
<q-item-label caption>{{ tool.language }}</q-item-label>
</q-item-section>
{% endraw %}
</q-item>
</q-list>
<q-separator class="q-my-lg"></q-separator>
<p>A magical "g" is always available, with info about the user, wallets and extensions:</p>
<code class="text-caption">{% raw %}{{ g }}{% endraw %}</code>
</q-card-section>
</q-card>
{% 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>
Withdraw link maker
<small>powered by LNURL</small>
</h1>
<ol class="breadcrumb">
<li>
<a href="{{ url_for('wallet') }}?usr={{ user }}"><i class="fa fa-dashboard"></i> Home</a>
</li>
<li>
<a href="{{ url_for('extensions') }}?usr={{ user }}"><li class="fa fa-dashboard">Extensions</li></a>
</li>
<li>
<i class="active" class="fa fa-dashboard">example</i>
</li>
</ol>
<br /><br />
<!-- DOWNLOAD AND SEARCH ADMINLITE2 FOR HTML-->
</section>
<!-- 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"> EXAMPLE BOX HEADING</h3>
</div><!-- /.box-header -->
<div class="box-body">
<p>*Some content in here</p>
</div>
</div><!-- /.box -->
</div>
</div>
</section>
{% block scripts %}
{{ window_vars(user) }}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
tools: []
};
},
created: function () {
var self = this;
//ADD YOUR JAVASCIPT HERE//
// axios is available for making requests
axios({
method: 'GET',
url: '/example/api/v1/tools',
headers: {
'X-example-header': 'not-used'
}
}).then(function (response) {
self.tools = response.data;
});
}
});
</script>
</div>
{% endblock %}
+5 -20
View File
@@ -1,26 +1,11 @@
#add your dependencies here
from flask import g, render_template
from flask import jsonify, render_template, request, redirect, url_for
from lnbits.db import open_db, open_ext_db
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.example import example_ext
#add your endpoints here
@example_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
"""Try to add descriptions for others."""
usr = request.args.get("usr")
if usr:
if not len(usr) > 20:
return redirect(url_for("home"))
# Get all the data
with open_db() as db:
user_wallets = db.fetchall("SELECT * FROM wallets WHERE user = ?", (usr,))
user_ext = db.fetchall("SELECT extension FROM extensions WHERE user = ? AND active = 1", (usr,))
user_ext = [v[0] for v in user_ext]
return render_template(
"example/index.html"
)
return render_template("example/index.html", user=g.user)
+30 -11
View File
@@ -1,18 +1,37 @@
#views_api.py is for you API endpoints that could be hit by another service
# views_api.py is for you API endpoints that could be hit by another service
#add your dependencies here
# add your dependencies here
import json
import requests
from flask import jsonify, render_template, request, redirect, url_for
from lnbits.db import open_db, open_ext_db
# import json
# import requests
from flask import jsonify
from lnbits.helpers import Status
from lnbits.extensions.example import example_ext
#add your endpoints here
@example_ext.route("/api/v1/example", methods=["GET","POST"])
# add your endpoints here
@example_ext.route("/api/v1/tools", methods=["GET"])
def api_example():
"""Try to add descriptions for others."""
#YOUR-CODE
return jsonify({"status": "TRUE"}), 200
tools = [
{
"name": "Flask",
"url": "https://flask.palletsprojects.com/",
"language": "Python",
},
{
"name": "Vue.js",
"url": "https://vuejs.org/",
"language": "JavaScript",
},
{
"name": "Quasar Framework",
"url": "https://quasar.dev/",
"language": "JavaScript",
}
]
return jsonify(tools), Status.OK
+11
View File
@@ -0,0 +1,11 @@
<h1>Example Extension</h1>
<h2>*tagline*</h2>
This is an example extension to help you organise and build you own.
Try to include an image
<img src="https://i.imgur.com/9i4xcQB.png">
<h2>If your extension has API endpoints, include useful ones here</h2>
<code>curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"</code>
+8
View File
@@ -0,0 +1,8 @@
from flask import Blueprint
paywall_ext: Blueprint = Blueprint("paywall", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
from .views import * # noqa
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Paywall",
"short_description": "Create paywalls for content",
"icon": "vpn_lock",
"contributors": ["eillarra"]
}
+43
View File
@@ -0,0 +1,43 @@
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from lnbits.helpers import urlsafe_short_hash
from .models import Paywall
def create_paywall(*, wallet_id: str, url: str, memo: str, amount: int) -> Paywall:
with open_ext_db("paywall") as db:
paywall_id = urlsafe_short_hash()
db.execute(
"""
INSERT INTO paywalls (id, wallet, secret, url, memo, amount)
VALUES (?, ?, ?, ?, ?, ?)
""",
(paywall_id, wallet_id, urlsafe_short_hash(), url, memo, amount),
)
return get_paywall(paywall_id)
def get_paywall(paywall_id: str) -> Optional[Paywall]:
with open_ext_db("paywall") as db:
row = db.fetchone("SELECT * FROM paywalls WHERE id = ?", (paywall_id,))
return Paywall(**row) if row else None
def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("paywall") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM paywalls WHERE wallet IN ({q})", (*wallet_ids,))
return [Paywall(**row) for row in rows]
def delete_paywall(paywall_id: str) -> None:
with open_ext_db("paywall") as db:
db.execute("DELETE FROM paywalls WHERE id = ?", (paywall_id,))
+25
View File
@@ -0,0 +1,25 @@
from lnbits.db import open_ext_db
def m001_initial(db):
"""
Initial paywalls table.
"""
db.execute(
"""
CREATE TABLE IF NOT EXISTS paywalls (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
secret TEXT NOT NULL,
url TEXT NOT NULL,
memo TEXT NOT NULL,
amount INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
);
"""
)
def migrate():
with open_ext_db("paywall") as db:
m001_initial(db)
+11
View File
@@ -0,0 +1,11 @@
from typing import NamedTuple
class Paywall(NamedTuple):
id: str
wallet: str
secret: str
url: str
memo: str
amount: int
time: int
@@ -0,0 +1,28 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List paywalls">
<q-card>
<q-card-section>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a paywall">
<q-card>
<q-card-section>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Delete a paywall" class="q-pb-md">
<q-card>
<q-card-section>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
@@ -0,0 +1,107 @@
{% extends "public.html" %}
{% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<h5 class="text-subtitle1 q-my-none">{{ paywall.memo }}</h5>
<strong class="text-purple">Price: <lnbits-fsat :amount="{{ paywall.amount }}"></lnbits-fsat> sat</strong>
<q-separator class="q-my-lg"></q-separator>
<div v-if="paymentReq">
<a :href="'lightning:' + paymentReq">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode :value="paymentReq" :options="{width: 800}" class="rounded-borders"></qrcode>
</q-responsive>
</a>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(paymentReq)">Copy invoice</q-btn>
</div>
</div>
<div v-if="redirectUrl">
<p>You can access the URL behind this paywall:<br>
<strong>{% raw %}{{ redirectUrl }}{% endraw %}</strong></p>
<div class="row q-mt-lg">
<q-btn outline color="grey" type="a" :href="redirectUrl">Open URL</q-btn>
</div>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-sm-6 col-md-5 col-lg-4 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits paywall</h6>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode);
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
paymentReq: null,
redirectUrl: null
};
},
methods: {
getInvoice: function () {
var self = this;
axios.get(
'/paywall/api/v1/paywalls/{{ paywall.id }}/invoice'
).then(function (response) {
self.paymentReq = response.data.payment_request;
dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
});
paymentChecker = setInterval(function () {
axios.post(
'/paywall/api/v1/paywalls/{{ paywall.id }}/check_invoice',
{checking_id: response.data.checking_id}
).then(function (res) {
if (res.data.paid) {
clearInterval(paymentChecker);
dismissMsg();
self.redirectUrl = res.data.url;
self.$q.localStorage.set('lnbits.paywall.{{ paywall.id }}', res.data.url);
self.$q.notify({
type: 'positive',
message: 'Payment received!',
icon: null
});
}
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
}, 2000);
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
}
},
created: function () {
var url = this.$q.localStorage.getItem('lnbits.paywall.{{ paywall.id }}');
if (url) {
this.redirectUrl = url;
} else {
this.getInvoice();
};
}
});
</script>
{% endblock %}
@@ -0,0 +1,214 @@
{% 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 paywall</q-btn>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Paywalls</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table dense flat
:data="paywalls"
row-key="id"
:columns="paywallsTable.columns"
:pagination.sync="paywallsTable.pagination">
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
>
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn unelevated dense size="xs" icon="launch" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" type="a" :href="props.row.displayUrl" target="_blank"></q-btn>
<q-btn unelevated dense size="xs" icon="link" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" type="a" :href="props.row.url" 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="deletePaywall(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 paywall extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "paywall/_api_docs.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="createPaywall" class="q-gutter-md">
<q-select filled dense emit-value v-model="formDialog.data.wallet" :options="g.user.walletOptions" label="Wallet *">
</q-select>
<q-input filled dense
v-model.trim="formDialog.data.url"
type="url"
label="Target URL *"></q-input>
<q-input filled dense
v-model.number="formDialog.data.amount"
type="number"
label="Amount (sat) *"></q-input>
<q-input filled dense
v-model.trim="formDialog.data.memo"
label="Memo"
placeholder="LNbits invoice"></q-input>
<div class="row q-mt-lg">
<q-btn unelevated
color="deep-purple"
:disable="formDialog.data.amount == null || formDialog.data.amount < 0 || formDialog.data.url == null"
type="submit">Create paywall</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %}
{% block scripts %}
{{ window_vars(user) }}
<script>
var mapPaywall = 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 = ['/paywall/', obj.id].join('');
return obj;
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
paywalls: [],
paywallsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'memo', align: 'left', label: 'Memo', field: 'memo'},
{name: 'date', align: 'left', label: 'Date', field: 'date', sortable: true},
{
name: 'amount', align: 'right', label: 'Amount (sat)', field: 'fsat', sortable: true,
sort: function (a, b, rowA, rowB) {
return rowA.amount - rowB.amount;
}
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
}
};
},
methods: {
getPaywalls: function () {
var self = this;
LNbits.api.request(
'GET',
'/paywall/api/v1/paywalls?all_wallets',
this.g.user.wallets[0].inkey
).then(function (response) {
self.paywalls = response.data.map(function (obj) {
return mapPaywall(obj);
});
});
},
createPaywall: function () {
var data = {
url: this.formDialog.data.url,
memo: this.formDialog.data.memo,
amount: this.formDialog.data.amount
};
var self = this;
LNbits.api.request(
'POST',
'/paywall/api/v1/paywalls',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet}).inkey,
data
).then(function (response) {
self.paywalls.push(mapPaywall(response.data));
self.formDialog.show = false;
self.formDialog.data = {};
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
},
deletePaywall: function (paywallId) {
var self = this;
var paywall = _.findWhere(this.paywalls, {id: paywallId});
LNbits.utils.confirmDialog(
'Are you sure you want to delete this paywall link?'
).onOk(function () {
LNbits.api.request(
'DELETE',
'/paywall/api/v1/paywalls/' + paywallId,
_.findWhere(self.g.user.wallets, {id: paywall.wallet}).inkey
).then(function (response) {
self.paywalls = _.reject(self.paywalls, function (obj) { return obj.id == paywallId; });
}).catch(function (error) {
LNbits.utils.notifyApiError(error);
});
});
},
exportCSV: function () {
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls);
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getPaywalls();
}
}
});
</script>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
from flask import g, abort, render_template
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.helpers import Status
from lnbits.extensions.paywall import paywall_ext
from .crud import get_paywall
@paywall_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
return render_template("paywall/index.html", user=g.user)
@paywall_ext.route("/<paywall_id>")
def display(paywall_id):
paywall = get_paywall(paywall_id) or abort(Status.NOT_FOUND, "Paywall does not exist.")
return render_template("paywall/display.html", paywall=paywall)
+89
View File
@@ -0,0 +1,89 @@
from flask import g, jsonify, request
from lnbits.core.crud import get_user, get_wallet
from lnbits.core.services import create_invoice
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.helpers import Status
from lnbits.settings import WALLET
from lnbits.extensions.paywall import paywall_ext
from .crud import create_paywall, get_paywall, get_paywalls, delete_paywall
@paywall_ext.route("/api/v1/paywalls", methods=["GET"])
@api_check_wallet_key("invoice")
def api_paywalls():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
return jsonify([paywall._asdict() for paywall in get_paywalls(wallet_ids)]), Status.OK
@paywall_ext.route("/api/v1/paywalls", methods=["POST"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"url": {"type": "string", "empty": False, "required": True},
"memo": {"type": "string", "empty": False, "required": True},
"amount": {"type": "integer", "min": 0, "required": True},
}
)
def api_paywall_create():
paywall = create_paywall(wallet_id=g.wallet.id, **g.data)
return jsonify(paywall._asdict()), Status.CREATED
@paywall_ext.route("/api/v1/paywalls/<paywall_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
def api_paywall_delete(paywall_id):
paywall = get_paywall(paywall_id)
if not paywall:
return jsonify({"message": "Paywall does not exist."}), Status.NOT_FOUND
if paywall.wallet != g.wallet.id:
return jsonify({"message": "Not your paywall."}), Status.FORBIDDEN
delete_paywall(paywall_id)
return "", Status.NO_CONTENT
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/invoice", methods=["GET"])
def api_paywall_get_invoice(paywall_id):
paywall = get_paywall(paywall_id)
try:
checking_id, payment_request = create_invoice(
wallet_id=paywall.wallet, amount=paywall.amount, memo=f"#paywall {paywall.memo}"
)
except Exception as e:
return jsonify({"message": str(e)}), Status.INTERNAL_SERVER_ERROR
return jsonify({"checking_id": checking_id, "payment_request": payment_request}), Status.OK
@paywall_ext.route("/api/v1/paywalls/<paywall_id>/check_invoice", methods=["POST"])
@api_validate_post_request(schema={"checking_id": {"type": "string", "empty": False, "required": True}})
def api_paywal_check_invoice(paywall_id):
paywall = get_paywall(paywall_id)
if not paywall:
return jsonify({"message": "Paywall does not exist."}), Status.NOT_FOUND
try:
is_paid = not WALLET.get_invoice_status(g.data["checking_id"]).pending
except Exception:
return jsonify({"paid": False}), Status.OK
if is_paid:
wallet = get_wallet(paywall.wallet)
payment = wallet.get_payment(g.data["checking_id"])
payment.set_pending(False)
return jsonify({"paid": True, "url": paywall.url}), Status.OK
return jsonify({"paid": False}), Status.OK
+1 -1
View File
@@ -1,7 +1,7 @@
from flask import Blueprint
tpos_ext = Blueprint("tpos", __name__, static_folder="static", template_folder="templates")
tpos_ext: Blueprint = Blueprint("tpos", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
+4 -3
View File
@@ -1,5 +1,6 @@
{
"name": "TPOS",
"short_description": "A shareable POS!",
"ion_icon": "calculator"
"name": "TPoS",
"short_description": "A shareable PoS terminal!",
"icon": "dialpad",
"contributors": ["talvasconcelos", "arcbtc"]
}
+43
View File
@@ -0,0 +1,43 @@
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from lnbits.helpers import urlsafe_short_hash
from .models import TPoS
def create_tpos(*, wallet_id: str, name: str, currency: str) -> TPoS:
with open_ext_db("tpos") as db:
tpos_id = urlsafe_short_hash()
db.execute(
"""
INSERT INTO tposs (id, wallet, name, currency)
VALUES (?, ?, ?, ?)
""",
(tpos_id, wallet_id, name, currency),
)
return get_tpos(tpos_id)
def get_tpos(tpos_id: str) -> Optional[TPoS]:
with open_ext_db("tpos") as db:
row = db.fetchone("SELECT * FROM tposs WHERE id = ?", (tpos_id,))
return TPoS(**row) if row else None
def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("tpos") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM tposs WHERE wallet IN ({q})", (*wallet_ids,))
return [TPoS(**row) for row in rows]
def delete_tpos(tpos_id: str) -> None:
with open_ext_db("tpos") as db:
db.execute("DELETE FROM tposs WHERE id = ?", (tpos_id,))
+22
View File
@@ -0,0 +1,22 @@
from lnbits.db import open_ext_db
def m001_initial(db):
"""
Initial tposs table.
"""
db.execute(
"""
CREATE TABLE IF NOT EXISTS tposs (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
currency TEXT NOT NULL
);
"""
)
def migrate():
with open_ext_db("tpos") as db:
m001_initial(db)
+8
View File
@@ -0,0 +1,8 @@
from typing import NamedTuple
class TPoS(NamedTuple):
id: str
wallet: str
name: str
currency: str
-9
View File
@@ -1,9 +0,0 @@
/* create your extensions table and the variables needed here */
CREATE TABLE IF NOT EXISTS tpos (
key INTEGER PRIMARY KEY AUTOINCREMENT,
nme TEXT,
uni TEXT,
usr TEXT,
invkey TEXT,
cur TEXT
);

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