From 9c313586bd387c23628dcf59df9a4ba74ee78e77 Mon Sep 17 00:00:00 2001 From: Ben Arc Date: Mon, 16 Aug 2021 18:12:48 +0100 Subject: [PATCH] removed all but one extension, so we can focus on core --- lnbits/extensions/amilk/README.md | 11 - lnbits/extensions/amilk/__init__.py | 12 - lnbits/extensions/amilk/config.json | 6 - lnbits/extensions/amilk/crud.py | 42 - lnbits/extensions/amilk/migrations.py | 15 - lnbits/extensions/amilk/models.py | 9 - .../amilk/templates/amilk/_api_docs.html | 24 - .../amilk/templates/amilk/index.html | 250 ----- lnbits/extensions/amilk/views.py | 23 - lnbits/extensions/amilk/views_api.py | 105 -- lnbits/extensions/bleskomat/README.md | 21 - lnbits/extensions/bleskomat/__init__.py | 12 - lnbits/extensions/bleskomat/config.json | 6 - lnbits/extensions/bleskomat/crud.py | 119 --- lnbits/extensions/bleskomat/exchange_rates.py | 79 -- .../extensions/bleskomat/fiat_currencies.json | 166 ---- lnbits/extensions/bleskomat/helpers.py | 153 --- lnbits/extensions/bleskomat/lnurl_api.py | 134 --- lnbits/extensions/bleskomat/migrations.py | 37 - lnbits/extensions/bleskomat/models.py | 110 --- .../extensions/bleskomat/static/js/index.js | 216 ----- .../templates/bleskomat/_api_docs.html | 65 -- .../bleskomat/templates/bleskomat/index.html | 180 ---- lnbits/extensions/bleskomat/views.py | 22 - lnbits/extensions/bleskomat/views_api.py | 120 --- lnbits/extensions/captcha/README.md | 11 - lnbits/extensions/captcha/__init__.py | 12 - lnbits/extensions/captcha/config.json | 6 - lnbits/extensions/captcha/crud.py | 53 - lnbits/extensions/captcha/migrations.py | 63 -- lnbits/extensions/captcha/models.py | 23 - .../extensions/captcha/static/js/captcha.js | 82 -- .../captcha/templates/captcha/_api_docs.html | 147 --- .../captcha/templates/captcha/display.html | 178 ---- .../captcha/templates/captcha/index.html | 427 --------- lnbits/extensions/captcha/views.py | 22 - lnbits/extensions/captcha/views_api.py | 121 --- lnbits/extensions/diagonalley/README.md | 10 - lnbits/extensions/diagonalley/__init__.py | 10 - .../diagonalley/config.json.example | 6 - lnbits/extensions/diagonalley/crud.py | 308 ------ lnbits/extensions/diagonalley/migrations.py | 60 -- lnbits/extensions/diagonalley/models.py | 40 - .../templates/diagonalley/_api_docs.html | 122 --- .../templates/diagonalley/index.html | 906 ------------------ .../templates/diagonalley/stall.html | 3 - lnbits/extensions/diagonalley/views.py | 11 - lnbits/extensions/diagonalley/views_api.py | 360 ------- lnbits/extensions/events/README.md | 33 - lnbits/extensions/events/__init__.py | 13 - lnbits/extensions/events/config.json | 6 - lnbits/extensions/events/crud.py | 168 ---- lnbits/extensions/events/migrations.py | 91 -- lnbits/extensions/events/models.py | 26 - .../events/templates/events/_api_docs.html | 23 - .../events/templates/events/display.html | 207 ---- .../events/templates/events/error.html | 35 - .../events/templates/events/index.html | 538 ----------- .../events/templates/events/register.html | 173 ---- .../events/templates/events/ticket.html | 45 - lnbits/extensions/events/views.py | 76 -- lnbits/extensions/events/views_api.py | 207 ---- lnbits/extensions/example/README.md | 11 - lnbits/extensions/example/__init__.py | 12 - lnbits/extensions/example/config.json | 6 - lnbits/extensions/example/migrations.py | 10 - lnbits/extensions/example/models.py | 11 - .../example/templates/example/index.html | 59 -- lnbits/extensions/example/views.py | 12 - lnbits/extensions/example/views_api.py | 40 - lnbits/extensions/hivemind/README.md | 3 - lnbits/extensions/hivemind/__init__.py | 11 - lnbits/extensions/hivemind/config.json | 6 - lnbits/extensions/hivemind/migrations.py | 10 - lnbits/extensions/hivemind/models.py | 11 - .../hivemind/templates/hivemind/index.html | 35 - lnbits/extensions/hivemind/views.py | 12 - lnbits/extensions/jukebox/README.md | 36 - lnbits/extensions/jukebox/__init__.py | 17 - lnbits/extensions/jukebox/config.json | 6 - lnbits/extensions/jukebox/crud.py | 122 --- lnbits/extensions/jukebox/migrations.py | 39 - lnbits/extensions/jukebox/models.py | 33 - lnbits/extensions/jukebox/static/js/index.js | 420 -------- .../extensions/jukebox/static/js/jukebox.js | 14 - lnbits/extensions/jukebox/static/spotapi.gif | Bin 219995 -> 0 bytes lnbits/extensions/jukebox/static/spotapi1.gif | Bin 246647 -> 0 bytes lnbits/extensions/jukebox/tasks.py | 28 - .../jukebox/templates/jukebox/_api_docs.html | 125 --- .../jukebox/templates/jukebox/error.html | 37 - .../jukebox/templates/jukebox/index.html | 368 ------- .../jukebox/templates/jukebox/jukebox.html | 277 ------ lnbits/extensions/jukebox/views.py | 42 - lnbits/extensions/jukebox/views_api.py | 491 ---------- lnbits/extensions/livestream/README.md | 45 - lnbits/extensions/livestream/__init__.py | 19 - lnbits/extensions/livestream/config.json | 10 - lnbits/extensions/livestream/crud.py | 199 ---- lnbits/extensions/livestream/lnurl.py | 114 --- lnbits/extensions/livestream/migrations.py | 39 - lnbits/extensions/livestream/models.py | 81 -- .../extensions/livestream/static/js/index.js | 216 ----- lnbits/extensions/livestream/tasks.py | 89 -- .../templates/livestream/_api_docs.html | 146 --- .../templates/livestream/index.html | 322 ------- lnbits/extensions/livestream/views.py | 38 - lnbits/extensions/livestream/views_api.py | 135 --- lnbits/extensions/lndhub/README.md | 6 - lnbits/extensions/lndhub/__init__.py | 12 - lnbits/extensions/lndhub/config.json | 6 - lnbits/extensions/lndhub/decorators.py | 29 - lnbits/extensions/lndhub/migrations.py | 2 - .../templates/lndhub/_instructions.html | 35 - .../lndhub/templates/lndhub/_lndhub.html | 19 - .../lndhub/templates/lndhub/index.html | 94 -- lnbits/extensions/lndhub/utils.py | 21 - lnbits/extensions/lndhub/views.py | 11 - lnbits/extensions/lndhub/views_api.py | 240 ----- lnbits/extensions/lnticket/README.md | 29 - lnbits/extensions/lnticket/__init__.py | 17 - lnbits/extensions/lnticket/config.json | 6 - lnbits/extensions/lnticket/crud.py | 156 --- lnbits/extensions/lnticket/migrations.py | 202 ---- lnbits/extensions/lnticket/models.py | 25 - lnbits/extensions/lnticket/tasks.py | 37 - .../templates/lnticket/_api_docs.html | 22 - .../lnticket/templates/lnticket/display.html | 202 ---- .../lnticket/templates/lnticket/index.html | 490 ---------- lnbits/extensions/lnticket/views.py | 34 - lnbits/extensions/lnticket/views_api.py | 179 ---- lnbits/extensions/lnurlp/README.md | 27 - lnbits/extensions/lnurlp/__init__.py | 18 - lnbits/extensions/lnurlp/config.json | 10 - lnbits/extensions/lnurlp/crud.py | 103 -- lnbits/extensions/lnurlp/lnurl.py | 111 --- lnbits/extensions/lnurlp/migrations.py | 52 - lnbits/extensions/lnurlp/models.py | 55 -- lnbits/extensions/lnurlp/static/js/index.js | 227 ----- lnbits/extensions/lnurlp/tasks.py | 61 -- .../lnurlp/templates/lnurlp/_api_docs.html | 128 --- .../lnurlp/templates/lnurlp/_lnurl.html | 28 - .../lnurlp/templates/lnurlp/display.html | 47 - .../lnurlp/templates/lnurlp/index.html | 312 ------ .../lnurlp/templates/lnurlp/print_qr.html | 27 - lnbits/extensions/lnurlp/views.py | 32 - lnbits/extensions/lnurlp/views_api.py | 142 --- lnbits/extensions/ngrok/README.md | 20 - lnbits/extensions/ngrok/__init__.py | 8 - lnbits/extensions/ngrok/__pycache__/.gitkeep | 1 - lnbits/extensions/ngrok/config.json.example | 6 - lnbits/extensions/ngrok/migrations.py | 11 - .../ngrok/templates/ngrok/index.html | 53 - lnbits/extensions/ngrok/views.py | 30 - lnbits/extensions/offlineshop/README.md | 36 - lnbits/extensions/offlineshop/__init__.py | 14 - lnbits/extensions/offlineshop/config.json | 8 - lnbits/extensions/offlineshop/crud.py | 113 --- lnbits/extensions/offlineshop/helpers.py | 17 - lnbits/extensions/offlineshop/lnurl.py | 87 -- lnbits/extensions/offlineshop/migrations.py | 29 - lnbits/extensions/offlineshop/models.py | 120 --- .../extensions/offlineshop/static/js/index.js | 220 ----- .../templates/offlineshop/_api_docs.html | 147 --- .../templates/offlineshop/index.html | 335 ------- .../templates/offlineshop/print.html | 25 - lnbits/extensions/offlineshop/views.py | 70 -- lnbits/extensions/offlineshop/views_api.py | 128 --- lnbits/extensions/offlineshop/wordlists.py | 28 - lnbits/extensions/paywall/README.md | 22 - lnbits/extensions/paywall/__init__.py | 12 - lnbits/extensions/paywall/config.json | 6 - lnbits/extensions/paywall/crud.py | 53 - lnbits/extensions/paywall/migrations.py | 66 -- lnbits/extensions/paywall/models.py | 23 - .../paywall/templates/paywall/_api_docs.html | 147 --- .../paywall/templates/paywall/display.html | 162 ---- .../paywall/templates/paywall/index.html | 312 ------ lnbits/extensions/paywall/views.py | 22 - lnbits/extensions/paywall/views_api.py | 121 --- lnbits/extensions/satspay/README.md | 27 - lnbits/extensions/satspay/__init__.py | 13 - lnbits/extensions/satspay/config.json | 8 - lnbits/extensions/satspay/crud.py | 130 --- lnbits/extensions/satspay/migrations.py | 28 - lnbits/extensions/satspay/models.py | 39 - .../satspay/templates/satspay/_api_docs.html | 171 ---- .../satspay/templates/satspay/display.html | 318 ------ .../satspay/templates/satspay/index.html | 555 ----------- lnbits/extensions/satspay/views.py | 22 - lnbits/extensions/satspay/views_api.py | 157 --- lnbits/extensions/splitpayments/README.md | 34 - lnbits/extensions/splitpayments/__init__.py | 18 - lnbits/extensions/splitpayments/config.json | 9 - lnbits/extensions/splitpayments/crud.py | 27 - lnbits/extensions/splitpayments/migrations.py | 16 - lnbits/extensions/splitpayments/models.py | 8 - .../splitpayments/static/js/index.js | 143 --- lnbits/extensions/splitpayments/tasks.py | 77 -- .../templates/splitpayments/_api_docs.html | 90 -- .../templates/splitpayments/index.html | 100 -- lnbits/extensions/splitpayments/views.py | 12 - lnbits/extensions/splitpayments/views_api.py | 70 -- lnbits/extensions/streamalerts/README.md | 39 - lnbits/extensions/streamalerts/__init__.py | 11 - lnbits/extensions/streamalerts/config.json | 6 - lnbits/extensions/streamalerts/crud.py | 297 ------ lnbits/extensions/streamalerts/migrations.py | 35 - lnbits/extensions/streamalerts/models.py | 44 - .../templates/streamalerts/_api_docs.html | 18 - .../templates/streamalerts/display.html | 97 -- .../templates/streamalerts/index.html | 502 ---------- lnbits/extensions/streamalerts/views.py | 28 - lnbits/extensions/streamalerts/views_api.py | 273 ------ lnbits/extensions/subdomains/README.md | 54 -- lnbits/extensions/subdomains/__init__.py | 17 - lnbits/extensions/subdomains/cloudflare.py | 60 -- lnbits/extensions/subdomains/config.json | 6 - lnbits/extensions/subdomains/crud.py | 182 ---- lnbits/extensions/subdomains/migrations.py | 41 - lnbits/extensions/subdomains/models.py | 30 - lnbits/extensions/subdomains/tasks.py | 61 -- .../templates/subdomains/_api_docs.html | 26 - .../templates/subdomains/display.html | 221 ----- .../templates/subdomains/index.html | 550 ----------- lnbits/extensions/subdomains/util.py | 36 - lnbits/extensions/subdomains/views.py | 33 - lnbits/extensions/subdomains/views_api.py | 222 ----- lnbits/extensions/tpos/README.md | 15 - lnbits/extensions/tpos/__init__.py | 12 - lnbits/extensions/tpos/config.json | 6 - lnbits/extensions/tpos/crud.py | 42 - lnbits/extensions/tpos/migrations.py | 14 - lnbits/extensions/tpos/models.py | 13 - .../tpos/templates/tpos/_api_docs.html | 78 -- .../extensions/tpos/templates/tpos/_tpos.html | 18 - .../extensions/tpos/templates/tpos/index.html | 423 -------- .../extensions/tpos/templates/tpos/tpos.html | 264 ----- lnbits/extensions/tpos/views.py | 23 - lnbits/extensions/tpos/views_api.py | 101 -- lnbits/extensions/usermanager/README.md | 26 - lnbits/extensions/usermanager/__init__.py | 12 - lnbits/extensions/usermanager/config.json | 6 - lnbits/extensions/usermanager/crud.py | 122 --- lnbits/extensions/usermanager/migrations.py | 31 - lnbits/extensions/usermanager/models.py | 23 - .../templates/usermanager/_api_docs.html | 259 ----- .../templates/usermanager/index.html | 473 --------- lnbits/extensions/usermanager/views.py | 12 - lnbits/extensions/usermanager/views_api.py | 156 --- lnbits/extensions/watchonly/README.md | 19 - lnbits/extensions/watchonly/__init__.py | 13 - lnbits/extensions/watchonly/config.json | 8 - lnbits/extensions/watchonly/crud.py | 212 ---- lnbits/extensions/watchonly/migrations.py | 36 - lnbits/extensions/watchonly/models.py | 35 - .../templates/watchonly/_api_docs.html | 244 ----- .../watchonly/templates/watchonly/index.html | 476 --------- lnbits/extensions/watchonly/views.py | 22 - lnbits/extensions/watchonly/views_api.py | 138 --- lnbits/extensions/withdraw/README.md | 46 - lnbits/extensions/withdraw/__init__.py | 14 - lnbits/extensions/withdraw/config.json | 6 - lnbits/extensions/withdraw/crud.py | 159 --- lnbits/extensions/withdraw/lnurl.py | 139 --- lnbits/extensions/withdraw/migrations.py | 110 --- lnbits/extensions/withdraw/models.py | 76 -- lnbits/extensions/withdraw/static/js/index.js | 246 ----- .../templates/withdraw/_api_docs.html | 199 ---- .../withdraw/templates/withdraw/_lnurl.html | 29 - .../withdraw/templates/withdraw/display.html | 59 -- .../withdraw/templates/withdraw/index.html | 356 ------- .../withdraw/templates/withdraw/print_qr.html | 71 -- lnbits/extensions/withdraw/views.py | 63 -- lnbits/extensions/withdraw/views_api.py | 144 --- 274 files changed, 26032 deletions(-) delete mode 100644 lnbits/extensions/amilk/README.md delete mode 100644 lnbits/extensions/amilk/__init__.py delete mode 100644 lnbits/extensions/amilk/config.json delete mode 100644 lnbits/extensions/amilk/crud.py delete mode 100644 lnbits/extensions/amilk/migrations.py delete mode 100644 lnbits/extensions/amilk/models.py delete mode 100644 lnbits/extensions/amilk/templates/amilk/_api_docs.html delete mode 100644 lnbits/extensions/amilk/templates/amilk/index.html delete mode 100644 lnbits/extensions/amilk/views.py delete mode 100644 lnbits/extensions/amilk/views_api.py delete mode 100644 lnbits/extensions/bleskomat/README.md delete mode 100644 lnbits/extensions/bleskomat/__init__.py delete mode 100644 lnbits/extensions/bleskomat/config.json delete mode 100644 lnbits/extensions/bleskomat/crud.py delete mode 100644 lnbits/extensions/bleskomat/exchange_rates.py delete mode 100644 lnbits/extensions/bleskomat/fiat_currencies.json delete mode 100644 lnbits/extensions/bleskomat/helpers.py delete mode 100644 lnbits/extensions/bleskomat/lnurl_api.py delete mode 100644 lnbits/extensions/bleskomat/migrations.py delete mode 100644 lnbits/extensions/bleskomat/models.py delete mode 100644 lnbits/extensions/bleskomat/static/js/index.js delete mode 100644 lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html delete mode 100644 lnbits/extensions/bleskomat/templates/bleskomat/index.html delete mode 100644 lnbits/extensions/bleskomat/views.py delete mode 100644 lnbits/extensions/bleskomat/views_api.py delete mode 100644 lnbits/extensions/captcha/README.md delete mode 100644 lnbits/extensions/captcha/__init__.py delete mode 100644 lnbits/extensions/captcha/config.json delete mode 100644 lnbits/extensions/captcha/crud.py delete mode 100644 lnbits/extensions/captcha/migrations.py delete mode 100644 lnbits/extensions/captcha/models.py delete mode 100644 lnbits/extensions/captcha/static/js/captcha.js delete mode 100644 lnbits/extensions/captcha/templates/captcha/_api_docs.html delete mode 100644 lnbits/extensions/captcha/templates/captcha/display.html delete mode 100644 lnbits/extensions/captcha/templates/captcha/index.html delete mode 100644 lnbits/extensions/captcha/views.py delete mode 100644 lnbits/extensions/captcha/views_api.py delete mode 100644 lnbits/extensions/diagonalley/README.md delete mode 100644 lnbits/extensions/diagonalley/__init__.py delete mode 100644 lnbits/extensions/diagonalley/config.json.example delete mode 100644 lnbits/extensions/diagonalley/crud.py delete mode 100644 lnbits/extensions/diagonalley/migrations.py delete mode 100644 lnbits/extensions/diagonalley/models.py delete mode 100644 lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html delete mode 100644 lnbits/extensions/diagonalley/templates/diagonalley/index.html delete mode 100644 lnbits/extensions/diagonalley/templates/diagonalley/stall.html delete mode 100644 lnbits/extensions/diagonalley/views.py delete mode 100644 lnbits/extensions/diagonalley/views_api.py delete mode 100644 lnbits/extensions/events/README.md delete mode 100644 lnbits/extensions/events/__init__.py delete mode 100644 lnbits/extensions/events/config.json delete mode 100644 lnbits/extensions/events/crud.py delete mode 100644 lnbits/extensions/events/migrations.py delete mode 100644 lnbits/extensions/events/models.py delete mode 100644 lnbits/extensions/events/templates/events/_api_docs.html delete mode 100644 lnbits/extensions/events/templates/events/display.html delete mode 100644 lnbits/extensions/events/templates/events/error.html delete mode 100644 lnbits/extensions/events/templates/events/index.html delete mode 100644 lnbits/extensions/events/templates/events/register.html delete mode 100644 lnbits/extensions/events/templates/events/ticket.html delete mode 100644 lnbits/extensions/events/views.py delete mode 100644 lnbits/extensions/events/views_api.py delete mode 100644 lnbits/extensions/example/README.md delete mode 100644 lnbits/extensions/example/__init__.py delete mode 100644 lnbits/extensions/example/config.json delete mode 100644 lnbits/extensions/example/migrations.py delete mode 100644 lnbits/extensions/example/models.py delete mode 100644 lnbits/extensions/example/templates/example/index.html delete mode 100644 lnbits/extensions/example/views.py delete mode 100644 lnbits/extensions/example/views_api.py delete mode 100644 lnbits/extensions/hivemind/README.md delete mode 100644 lnbits/extensions/hivemind/__init__.py delete mode 100644 lnbits/extensions/hivemind/config.json delete mode 100644 lnbits/extensions/hivemind/migrations.py delete mode 100644 lnbits/extensions/hivemind/models.py delete mode 100644 lnbits/extensions/hivemind/templates/hivemind/index.html delete mode 100644 lnbits/extensions/hivemind/views.py delete mode 100644 lnbits/extensions/jukebox/README.md delete mode 100644 lnbits/extensions/jukebox/__init__.py delete mode 100644 lnbits/extensions/jukebox/config.json delete mode 100644 lnbits/extensions/jukebox/crud.py delete mode 100644 lnbits/extensions/jukebox/migrations.py delete mode 100644 lnbits/extensions/jukebox/models.py delete mode 100644 lnbits/extensions/jukebox/static/js/index.js delete mode 100644 lnbits/extensions/jukebox/static/js/jukebox.js delete mode 100644 lnbits/extensions/jukebox/static/spotapi.gif delete mode 100644 lnbits/extensions/jukebox/static/spotapi1.gif delete mode 100644 lnbits/extensions/jukebox/tasks.py delete mode 100644 lnbits/extensions/jukebox/templates/jukebox/_api_docs.html delete mode 100644 lnbits/extensions/jukebox/templates/jukebox/error.html delete mode 100644 lnbits/extensions/jukebox/templates/jukebox/index.html delete mode 100644 lnbits/extensions/jukebox/templates/jukebox/jukebox.html delete mode 100644 lnbits/extensions/jukebox/views.py delete mode 100644 lnbits/extensions/jukebox/views_api.py delete mode 100644 lnbits/extensions/livestream/README.md delete mode 100644 lnbits/extensions/livestream/__init__.py delete mode 100644 lnbits/extensions/livestream/config.json delete mode 100644 lnbits/extensions/livestream/crud.py delete mode 100644 lnbits/extensions/livestream/lnurl.py delete mode 100644 lnbits/extensions/livestream/migrations.py delete mode 100644 lnbits/extensions/livestream/models.py delete mode 100644 lnbits/extensions/livestream/static/js/index.js delete mode 100644 lnbits/extensions/livestream/tasks.py delete mode 100644 lnbits/extensions/livestream/templates/livestream/_api_docs.html delete mode 100644 lnbits/extensions/livestream/templates/livestream/index.html delete mode 100644 lnbits/extensions/livestream/views.py delete mode 100644 lnbits/extensions/livestream/views_api.py delete mode 100644 lnbits/extensions/lndhub/README.md delete mode 100644 lnbits/extensions/lndhub/__init__.py delete mode 100644 lnbits/extensions/lndhub/config.json delete mode 100644 lnbits/extensions/lndhub/decorators.py delete mode 100644 lnbits/extensions/lndhub/migrations.py delete mode 100644 lnbits/extensions/lndhub/templates/lndhub/_instructions.html delete mode 100644 lnbits/extensions/lndhub/templates/lndhub/_lndhub.html delete mode 100644 lnbits/extensions/lndhub/templates/lndhub/index.html delete mode 100644 lnbits/extensions/lndhub/utils.py delete mode 100644 lnbits/extensions/lndhub/views.py delete mode 100644 lnbits/extensions/lndhub/views_api.py delete mode 100644 lnbits/extensions/lnticket/README.md delete mode 100644 lnbits/extensions/lnticket/__init__.py delete mode 100644 lnbits/extensions/lnticket/config.json delete mode 100644 lnbits/extensions/lnticket/crud.py delete mode 100644 lnbits/extensions/lnticket/migrations.py delete mode 100644 lnbits/extensions/lnticket/models.py delete mode 100644 lnbits/extensions/lnticket/tasks.py delete mode 100644 lnbits/extensions/lnticket/templates/lnticket/_api_docs.html delete mode 100644 lnbits/extensions/lnticket/templates/lnticket/display.html delete mode 100644 lnbits/extensions/lnticket/templates/lnticket/index.html delete mode 100644 lnbits/extensions/lnticket/views.py delete mode 100644 lnbits/extensions/lnticket/views_api.py delete mode 100644 lnbits/extensions/lnurlp/README.md delete mode 100644 lnbits/extensions/lnurlp/__init__.py delete mode 100644 lnbits/extensions/lnurlp/config.json delete mode 100644 lnbits/extensions/lnurlp/crud.py delete mode 100644 lnbits/extensions/lnurlp/lnurl.py delete mode 100644 lnbits/extensions/lnurlp/migrations.py delete mode 100644 lnbits/extensions/lnurlp/models.py delete mode 100644 lnbits/extensions/lnurlp/static/js/index.js delete mode 100644 lnbits/extensions/lnurlp/tasks.py delete mode 100644 lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html delete mode 100644 lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html delete mode 100644 lnbits/extensions/lnurlp/templates/lnurlp/display.html delete mode 100644 lnbits/extensions/lnurlp/templates/lnurlp/index.html delete mode 100644 lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html delete mode 100644 lnbits/extensions/lnurlp/views.py delete mode 100644 lnbits/extensions/lnurlp/views_api.py delete mode 100644 lnbits/extensions/ngrok/README.md delete mode 100644 lnbits/extensions/ngrok/__init__.py delete mode 100644 lnbits/extensions/ngrok/__pycache__/.gitkeep delete mode 100644 lnbits/extensions/ngrok/config.json.example delete mode 100644 lnbits/extensions/ngrok/migrations.py delete mode 100644 lnbits/extensions/ngrok/templates/ngrok/index.html delete mode 100644 lnbits/extensions/ngrok/views.py delete mode 100644 lnbits/extensions/offlineshop/README.md delete mode 100644 lnbits/extensions/offlineshop/__init__.py delete mode 100644 lnbits/extensions/offlineshop/config.json delete mode 100644 lnbits/extensions/offlineshop/crud.py delete mode 100644 lnbits/extensions/offlineshop/helpers.py delete mode 100644 lnbits/extensions/offlineshop/lnurl.py delete mode 100644 lnbits/extensions/offlineshop/migrations.py delete mode 100644 lnbits/extensions/offlineshop/models.py delete mode 100644 lnbits/extensions/offlineshop/static/js/index.js delete mode 100644 lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html delete mode 100644 lnbits/extensions/offlineshop/templates/offlineshop/index.html delete mode 100644 lnbits/extensions/offlineshop/templates/offlineshop/print.html delete mode 100644 lnbits/extensions/offlineshop/views.py delete mode 100644 lnbits/extensions/offlineshop/views_api.py delete mode 100644 lnbits/extensions/offlineshop/wordlists.py delete mode 100644 lnbits/extensions/paywall/README.md delete mode 100644 lnbits/extensions/paywall/__init__.py delete mode 100644 lnbits/extensions/paywall/config.json delete mode 100644 lnbits/extensions/paywall/crud.py delete mode 100644 lnbits/extensions/paywall/migrations.py delete mode 100644 lnbits/extensions/paywall/models.py delete mode 100644 lnbits/extensions/paywall/templates/paywall/_api_docs.html delete mode 100644 lnbits/extensions/paywall/templates/paywall/display.html delete mode 100644 lnbits/extensions/paywall/templates/paywall/index.html delete mode 100644 lnbits/extensions/paywall/views.py delete mode 100644 lnbits/extensions/paywall/views_api.py delete mode 100644 lnbits/extensions/satspay/README.md delete mode 100644 lnbits/extensions/satspay/__init__.py delete mode 100644 lnbits/extensions/satspay/config.json delete mode 100644 lnbits/extensions/satspay/crud.py delete mode 100644 lnbits/extensions/satspay/migrations.py delete mode 100644 lnbits/extensions/satspay/models.py delete mode 100644 lnbits/extensions/satspay/templates/satspay/_api_docs.html delete mode 100644 lnbits/extensions/satspay/templates/satspay/display.html delete mode 100644 lnbits/extensions/satspay/templates/satspay/index.html delete mode 100644 lnbits/extensions/satspay/views.py delete mode 100644 lnbits/extensions/satspay/views_api.py delete mode 100644 lnbits/extensions/splitpayments/README.md delete mode 100644 lnbits/extensions/splitpayments/__init__.py delete mode 100644 lnbits/extensions/splitpayments/config.json delete mode 100644 lnbits/extensions/splitpayments/crud.py delete mode 100644 lnbits/extensions/splitpayments/migrations.py delete mode 100644 lnbits/extensions/splitpayments/models.py delete mode 100644 lnbits/extensions/splitpayments/static/js/index.js delete mode 100644 lnbits/extensions/splitpayments/tasks.py delete mode 100644 lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html delete mode 100644 lnbits/extensions/splitpayments/templates/splitpayments/index.html delete mode 100644 lnbits/extensions/splitpayments/views.py delete mode 100644 lnbits/extensions/splitpayments/views_api.py delete mode 100644 lnbits/extensions/streamalerts/README.md delete mode 100644 lnbits/extensions/streamalerts/__init__.py delete mode 100644 lnbits/extensions/streamalerts/config.json delete mode 100644 lnbits/extensions/streamalerts/crud.py delete mode 100644 lnbits/extensions/streamalerts/migrations.py delete mode 100644 lnbits/extensions/streamalerts/models.py delete mode 100644 lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html delete mode 100644 lnbits/extensions/streamalerts/templates/streamalerts/display.html delete mode 100644 lnbits/extensions/streamalerts/templates/streamalerts/index.html delete mode 100644 lnbits/extensions/streamalerts/views.py delete mode 100644 lnbits/extensions/streamalerts/views_api.py delete mode 100644 lnbits/extensions/subdomains/README.md delete mode 100644 lnbits/extensions/subdomains/__init__.py delete mode 100644 lnbits/extensions/subdomains/cloudflare.py delete mode 100644 lnbits/extensions/subdomains/config.json delete mode 100644 lnbits/extensions/subdomains/crud.py delete mode 100644 lnbits/extensions/subdomains/migrations.py delete mode 100644 lnbits/extensions/subdomains/models.py delete mode 100644 lnbits/extensions/subdomains/tasks.py delete mode 100644 lnbits/extensions/subdomains/templates/subdomains/_api_docs.html delete mode 100644 lnbits/extensions/subdomains/templates/subdomains/display.html delete mode 100644 lnbits/extensions/subdomains/templates/subdomains/index.html delete mode 100644 lnbits/extensions/subdomains/util.py delete mode 100644 lnbits/extensions/subdomains/views.py delete mode 100644 lnbits/extensions/subdomains/views_api.py delete mode 100644 lnbits/extensions/tpos/README.md delete mode 100644 lnbits/extensions/tpos/__init__.py delete mode 100644 lnbits/extensions/tpos/config.json delete mode 100644 lnbits/extensions/tpos/crud.py delete mode 100644 lnbits/extensions/tpos/migrations.py delete mode 100644 lnbits/extensions/tpos/models.py delete mode 100644 lnbits/extensions/tpos/templates/tpos/_api_docs.html delete mode 100644 lnbits/extensions/tpos/templates/tpos/_tpos.html delete mode 100644 lnbits/extensions/tpos/templates/tpos/index.html delete mode 100644 lnbits/extensions/tpos/templates/tpos/tpos.html delete mode 100644 lnbits/extensions/tpos/views.py delete mode 100644 lnbits/extensions/tpos/views_api.py delete mode 100644 lnbits/extensions/usermanager/README.md delete mode 100644 lnbits/extensions/usermanager/__init__.py delete mode 100644 lnbits/extensions/usermanager/config.json delete mode 100644 lnbits/extensions/usermanager/crud.py delete mode 100644 lnbits/extensions/usermanager/migrations.py delete mode 100644 lnbits/extensions/usermanager/models.py delete mode 100644 lnbits/extensions/usermanager/templates/usermanager/_api_docs.html delete mode 100644 lnbits/extensions/usermanager/templates/usermanager/index.html delete mode 100644 lnbits/extensions/usermanager/views.py delete mode 100644 lnbits/extensions/usermanager/views_api.py delete mode 100644 lnbits/extensions/watchonly/README.md delete mode 100644 lnbits/extensions/watchonly/__init__.py delete mode 100644 lnbits/extensions/watchonly/config.json delete mode 100644 lnbits/extensions/watchonly/crud.py delete mode 100644 lnbits/extensions/watchonly/migrations.py delete mode 100644 lnbits/extensions/watchonly/models.py delete mode 100644 lnbits/extensions/watchonly/templates/watchonly/_api_docs.html delete mode 100644 lnbits/extensions/watchonly/templates/watchonly/index.html delete mode 100644 lnbits/extensions/watchonly/views.py delete mode 100644 lnbits/extensions/watchonly/views_api.py delete mode 100644 lnbits/extensions/withdraw/README.md delete mode 100644 lnbits/extensions/withdraw/__init__.py delete mode 100644 lnbits/extensions/withdraw/config.json delete mode 100644 lnbits/extensions/withdraw/crud.py delete mode 100644 lnbits/extensions/withdraw/lnurl.py delete mode 100644 lnbits/extensions/withdraw/migrations.py delete mode 100644 lnbits/extensions/withdraw/models.py delete mode 100644 lnbits/extensions/withdraw/static/js/index.js delete mode 100644 lnbits/extensions/withdraw/templates/withdraw/_api_docs.html delete mode 100644 lnbits/extensions/withdraw/templates/withdraw/_lnurl.html delete mode 100644 lnbits/extensions/withdraw/templates/withdraw/display.html delete mode 100644 lnbits/extensions/withdraw/templates/withdraw/index.html delete mode 100644 lnbits/extensions/withdraw/templates/withdraw/print_qr.html delete mode 100644 lnbits/extensions/withdraw/views.py delete mode 100644 lnbits/extensions/withdraw/views_api.py diff --git a/lnbits/extensions/amilk/README.md b/lnbits/extensions/amilk/README.md deleted file mode 100644 index 27729459..00000000 --- a/lnbits/extensions/amilk/README.md +++ /dev/null @@ -1,11 +0,0 @@ -

Example Extension

-

*tagline*

-This is an example extension to help you organise and build you own. - -Try to include an image - - - -

If your extension has API endpoints, include useful ones here

- -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" diff --git a/lnbits/extensions/amilk/__init__.py b/lnbits/extensions/amilk/__init__.py deleted file mode 100644 index 0cdd8727..00000000 --- a/lnbits/extensions/amilk/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_amilk") - -amilk_ext: Blueprint = Blueprint( - "amilk", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/amilk/config.json b/lnbits/extensions/amilk/config.json deleted file mode 100644 index 09faf8af..00000000 --- a/lnbits/extensions/amilk/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "AMilk", - "short_description": "Assistant Faucet Milker", - "icon": "room_service", - "contributors": ["arcbtc"] -} diff --git a/lnbits/extensions/amilk/crud.py b/lnbits/extensions/amilk/crud.py deleted file mode 100644 index 859d2fa8..00000000 --- a/lnbits/extensions/amilk/crud.py +++ /dev/null @@ -1,42 +0,0 @@ -from base64 import urlsafe_b64encode -from uuid import uuid4 -from typing import List, Optional, Union - -from . import db -from .models import AMilk - - -async def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk: - amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") - await db.execute( - """ - INSERT INTO amilk.amilks (id, wallet, lnurl, atime, amount) - VALUES (?, ?, ?, ?, ?) - """, - (amilk_id, wallet_id, lnurl, atime, amount), - ) - - amilk = await get_amilk(amilk_id) - assert amilk, "Newly created amilk_id couldn't be retrieved" - return amilk - - -async def get_amilk(amilk_id: str) -> Optional[AMilk]: - row = await db.fetchone("SELECT * FROM amilk.amilks WHERE id = ?", (amilk_id,)) - return AMilk(**row) if row else None - - -async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM amilk.amilks WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [AMilk(**row) for row in rows] - - -async def delete_amilk(amilk_id: str) -> None: - await db.execute("DELETE FROM amilk.amilks WHERE id = ?", (amilk_id,)) diff --git a/lnbits/extensions/amilk/migrations.py b/lnbits/extensions/amilk/migrations.py deleted file mode 100644 index 596a8633..00000000 --- a/lnbits/extensions/amilk/migrations.py +++ /dev/null @@ -1,15 +0,0 @@ -async def m001_initial(db): - """ - Initial amilks table. - """ - await db.execute( - """ - CREATE TABLE amilk.amilks ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - lnurl TEXT NOT NULL, - atime INTEGER NOT NULL, - amount INTEGER NOT NULL - ); - """ - ) diff --git a/lnbits/extensions/amilk/models.py b/lnbits/extensions/amilk/models.py deleted file mode 100644 index a2acfa88..00000000 --- a/lnbits/extensions/amilk/models.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import NamedTuple - - -class AMilk(NamedTuple): - id: str - wallet: str - lnurl: str - atime: int - amount: int diff --git a/lnbits/extensions/amilk/templates/amilk/_api_docs.html b/lnbits/extensions/amilk/templates/amilk/_api_docs.html deleted file mode 100644 index f1c27a1b..00000000 --- a/lnbits/extensions/amilk/templates/amilk/_api_docs.html +++ /dev/null @@ -1,24 +0,0 @@ - - - -
Assistant Faucet Milker
-

- 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.
Paste or scan an - LNURL withdraw, enter the amount for the AMilk to pull and the frequency - for it to be pulled.
- - Created by, Ben Arc -

-
-
-
diff --git a/lnbits/extensions/amilk/templates/amilk/index.html b/lnbits/extensions/amilk/templates/amilk/index.html deleted file mode 100644 index bb332e27..00000000 --- a/lnbits/extensions/amilk/templates/amilk/index.html +++ /dev/null @@ -1,250 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New AMilk - - - - - -
-
-
AMilks
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
- -
- - -
- LNbits Assistant Faucet Milker Extension -
-
- - - {% include "amilk/_api_docs.html" %} - -
-
- - - - - - - - - - Create amilk - Cancel - - - -
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/amilk/views.py b/lnbits/extensions/amilk/views.py deleted file mode 100644 index 2f61df77..00000000 --- a/lnbits/extensions/amilk/views.py +++ /dev/null @@ -1,23 +0,0 @@ -from quart import g, abort, render_template -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import amilk_ext -from .crud import get_amilk - - -@amilk_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("amilk/index.html", user=g.user) - - -@amilk_ext.route("/") -async def wall(amilk_id): - amilk = await get_amilk(amilk_id) - if not amilk: - abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.") - - return await render_template("amilk/wall.html", amilk=amilk) diff --git a/lnbits/extensions/amilk/views_api.py b/lnbits/extensions/amilk/views_api.py deleted file mode 100644 index 4b8cad18..00000000 --- a/lnbits/extensions/amilk/views_api.py +++ /dev/null @@ -1,105 +0,0 @@ -import httpx -from quart import g, jsonify, request, abort -from http import HTTPStatus -from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore -from lnurl.exceptions import LnurlException # type: ignore -from time import sleep - -from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request -from lnbits.core.services import create_invoice, check_invoice_status - -from . import amilk_ext -from .crud import create_amilk, get_amilk, get_amilks, delete_amilk - - -@amilk_ext.route("/api/v1/amilk", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_amilks(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]), - HTTPStatus.OK, - ) - - -@amilk_ext.route("/api/v1/amilk/milk/", methods=["GET"]) -async def api_amilkit(amilk_id): - milk = await get_amilk(amilk_id) - memo = milk.id - - try: - withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse) - except LnurlException: - abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.") - - try: - payment_hash, payment_request = await create_invoice( - wallet_id=milk.wallet, - amount=withdraw_res.max_sats, - memo=memo, - extra={"tag": "amilk"}, - ) - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - - r = httpx.get( - withdraw_res.callback.base, - params={ - **withdraw_res.callback.query_params, - **{"k1": withdraw_res.k1, "pr": payment_request}, - }, - ) - - if r.is_error: - abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.") - - for i in range(10): - sleep(i) - invoice_status = await check_invoice_status(milk.wallet, payment_hash) - if invoice_status.paid: - return jsonify({"paid": True}), HTTPStatus.OK - else: - continue - - return jsonify({"paid": False}), HTTPStatus.OK - - -@amilk_ext.route("/api/v1/amilk", methods=["POST"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "lnurl": {"type": "string", "empty": False, "required": True}, - "atime": {"type": "integer", "min": 0, "required": True}, - "amount": {"type": "integer", "min": 0, "required": True}, - } -) -async def api_amilk_create(): - amilk = await create_amilk( - wallet_id=g.wallet.id, - lnurl=g.data["lnurl"], - atime=g.data["atime"], - amount=g.data["amount"], - ) - - return jsonify(amilk._asdict()), HTTPStatus.CREATED - - -@amilk_ext.route("/api/v1/amilk/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_amilk_delete(amilk_id): - amilk = await get_amilk(amilk_id) - - if not amilk: - return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND - - if amilk.wallet != g.wallet.id: - return jsonify({"message": "Not your amilk."}), HTTPStatus.FORBIDDEN - - await delete_amilk(amilk_id) - - return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/bleskomat/README.md b/lnbits/extensions/bleskomat/README.md deleted file mode 100644 index 97c70700..00000000 --- a/lnbits/extensions/bleskomat/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Bleskomat Extension for lnbits - -This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/). - - -## Connect Your Bleskomat ATM - -* Click the "Add Bleskomat" button on this page to begin. -* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers. -* Choose the fiat currency. This should match the fiat currency that your ATM accepts. -* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds. -* Set your ATM's fee percentage. -* Click the "Done" button. -* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM. -* Copy the configuration file ("bleskomat.conf") to your ATM's SD card. -* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card. - - -## How Does It Work? - -Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet. diff --git a/lnbits/extensions/bleskomat/__init__.py b/lnbits/extensions/bleskomat/__init__.py deleted file mode 100644 index 42f9bb46..00000000 --- a/lnbits/extensions/bleskomat/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_bleskomat") - -bleskomat_ext: Blueprint = Blueprint( - "bleskomat", __name__, static_folder="static", template_folder="templates" -) - -from .lnurl_api import * # noqa -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/bleskomat/config.json b/lnbits/extensions/bleskomat/config.json deleted file mode 100644 index 99244df1..00000000 --- a/lnbits/extensions/bleskomat/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Bleskomat", - "short_description": "Connect a Bleskomat ATM to an lnbits", - "icon": "money", - "contributors": ["chill117"] -} diff --git a/lnbits/extensions/bleskomat/crud.py b/lnbits/extensions/bleskomat/crud.py deleted file mode 100644 index 1cc44576..00000000 --- a/lnbits/extensions/bleskomat/crud.py +++ /dev/null @@ -1,119 +0,0 @@ -import secrets -import time -from uuid import uuid4 -from typing import List, Optional, Union -from . import db -from .models import Bleskomat, BleskomatLnurl -from .helpers import generate_bleskomat_lnurl_hash - - -async def create_bleskomat( - *, - wallet_id: str, - name: str, - fiat_currency: str, - exchange_rate_provider: str, - fee: str, -) -> Bleskomat: - bleskomat_id = uuid4().hex - api_key_id = secrets.token_hex(8) - api_key_secret = secrets.token_hex(32) - api_key_encoding = "hex" - await db.execute( - """ - INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - bleskomat_id, - wallet_id, - api_key_id, - api_key_secret, - api_key_encoding, - name, - fiat_currency, - exchange_rate_provider, - fee, - ), - ) - bleskomat = await get_bleskomat(bleskomat_id) - assert bleskomat, "Newly created bleskomat couldn't be retrieved" - return bleskomat - - -async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]: - row = await db.fetchone( - "SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,) - ) - return Bleskomat(**row) if row else None - - -async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]: - row = await db.fetchone( - "SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,) - ) - return Bleskomat(**row) if row else None - - -async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,) - ) - return [Bleskomat(**row) for row in rows] - - -async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?", - (*kwargs.values(), bleskomat_id), - ) - row = await db.fetchone( - "SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,) - ) - return Bleskomat(**row) if row else None - - -async def delete_bleskomat(bleskomat_id: str) -> None: - await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)) - - -async def create_bleskomat_lnurl( - *, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1 -) -> BleskomatLnurl: - bleskomat_lnurl_id = uuid4().hex - hash = generate_bleskomat_lnurl_hash(secret) - now = int(time.time()) - await db.execute( - """ - INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - bleskomat_lnurl_id, - bleskomat.id, - bleskomat.wallet, - hash, - tag, - params, - bleskomat.api_key_id, - uses, - uses, - now, - now, - ), - ) - bleskomat_lnurl = await get_bleskomat_lnurl(secret) - assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved" - return bleskomat_lnurl - - -async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]: - hash = generate_bleskomat_lnurl_hash(secret) - row = await db.fetchone( - "SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,) - ) - return BleskomatLnurl(**row) if row else None diff --git a/lnbits/extensions/bleskomat/exchange_rates.py b/lnbits/extensions/bleskomat/exchange_rates.py deleted file mode 100644 index 928a2823..00000000 --- a/lnbits/extensions/bleskomat/exchange_rates.py +++ /dev/null @@ -1,79 +0,0 @@ -import httpx -import json -import os - -fiat_currencies = json.load( - open( - os.path.join( - os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json" - ), - "r", - ) -) - -exchange_rate_providers = { - "bitfinex": { - "name": "Bitfinex", - "domain": "bitfinex.com", - "api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}", - "getter": lambda data, replacements: data["last_price"], - }, - "bitstamp": { - "name": "Bitstamp", - "domain": "bitstamp.net", - "api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/", - "getter": lambda data, replacements: data["last"], - }, - "coinbase": { - "name": "Coinbase", - "domain": "coinbase.com", - "api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}", - "getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]], - }, - "coinmate": { - "name": "CoinMate", - "domain": "coinmate.io", - "api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}", - "getter": lambda data, replacements: data["data"]["last"], - }, - "kraken": { - "name": "Kraken", - "domain": "kraken.com", - "api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}", - "getter": lambda data, replacements: data["result"][ - "XXBTZ" + replacements["TO"] - ]["c"][0], - }, -} - -exchange_rate_providers_serializable = {} -for ref, exchange_rate_provider in exchange_rate_providers.items(): - exchange_rate_provider_serializable = {} - for key, value in exchange_rate_provider.items(): - if not callable(value): - exchange_rate_provider_serializable[key] = value - exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable - - -async def fetch_fiat_exchange_rate(currency: str, provider: str): - - replacements = { - "FROM": "BTC", - "from": "btc", - "TO": currency.upper(), - "to": currency.lower(), - } - - url = exchange_rate_providers[provider]["api_url"] - for key in replacements.keys(): - url = url.replace("{" + key + "}", replacements[key]) - - getter = exchange_rate_providers[provider]["getter"] - - async with httpx.AsyncClient() as client: - r = await client.get(url) - r.raise_for_status() - data = r.json() - rate = float(getter(data, replacements)) - - return rate diff --git a/lnbits/extensions/bleskomat/fiat_currencies.json b/lnbits/extensions/bleskomat/fiat_currencies.json deleted file mode 100644 index ff831f3e..00000000 --- a/lnbits/extensions/bleskomat/fiat_currencies.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "AED": "United Arab Emirates Dirham", - "AFN": "Afghan Afghani", - "ALL": "Albanian Lek", - "AMD": "Armenian Dram", - "ANG": "Netherlands Antillean Gulden", - "AOA": "Angolan Kwanza", - "ARS": "Argentine Peso", - "AUD": "Australian Dollar", - "AWG": "Aruban Florin", - "AZN": "Azerbaijani Manat", - "BAM": "Bosnia and Herzegovina Convertible Mark", - "BBD": "Barbadian Dollar", - "BDT": "Bangladeshi Taka", - "BGN": "Bulgarian Lev", - "BHD": "Bahraini Dinar", - "BIF": "Burundian Franc", - "BMD": "Bermudian Dollar", - "BND": "Brunei Dollar", - "BOB": "Bolivian Boliviano", - "BRL": "Brazilian Real", - "BSD": "Bahamian Dollar", - "BTN": "Bhutanese Ngultrum", - "BWP": "Botswana Pula", - "BYN": "Belarusian Ruble", - "BYR": "Belarusian Ruble", - "BZD": "Belize Dollar", - "CAD": "Canadian Dollar", - "CDF": "Congolese Franc", - "CHF": "Swiss Franc", - "CLF": "Unidad de Fomento", - "CLP": "Chilean Peso", - "CNH": "Chinese Renminbi Yuan Offshore", - "CNY": "Chinese Renminbi Yuan", - "COP": "Colombian Peso", - "CRC": "Costa Rican Colón", - "CUC": "Cuban Convertible Peso", - "CVE": "Cape Verdean Escudo", - "CZK": "Czech Koruna", - "DJF": "Djiboutian Franc", - "DKK": "Danish Krone", - "DOP": "Dominican Peso", - "DZD": "Algerian Dinar", - "EGP": "Egyptian Pound", - "ERN": "Eritrean Nakfa", - "ETB": "Ethiopian Birr", - "EUR": "Euro", - "FJD": "Fijian Dollar", - "FKP": "Falkland Pound", - "GBP": "British Pound", - "GEL": "Georgian Lari", - "GGP": "Guernsey Pound", - "GHS": "Ghanaian Cedi", - "GIP": "Gibraltar Pound", - "GMD": "Gambian Dalasi", - "GNF": "Guinean Franc", - "GTQ": "Guatemalan Quetzal", - "GYD": "Guyanese Dollar", - "HKD": "Hong Kong Dollar", - "HNL": "Honduran Lempira", - "HRK": "Croatian Kuna", - "HTG": "Haitian Gourde", - "HUF": "Hungarian Forint", - "IDR": "Indonesian Rupiah", - "ILS": "Israeli New Sheqel", - "IMP": "Isle of Man Pound", - "INR": "Indian Rupee", - "IQD": "Iraqi Dinar", - "ISK": "Icelandic Króna", - "JEP": "Jersey Pound", - "JMD": "Jamaican Dollar", - "JOD": "Jordanian Dinar", - "JPY": "Japanese Yen", - "KES": "Kenyan Shilling", - "KGS": "Kyrgyzstani Som", - "KHR": "Cambodian Riel", - "KMF": "Comorian Franc", - "KRW": "South Korean Won", - "KWD": "Kuwaiti Dinar", - "KYD": "Cayman Islands Dollar", - "KZT": "Kazakhstani Tenge", - "LAK": "Lao Kip", - "LBP": "Lebanese Pound", - "LKR": "Sri Lankan Rupee", - "LRD": "Liberian Dollar", - "LSL": "Lesotho Loti", - "LYD": "Libyan Dinar", - "MAD": "Moroccan Dirham", - "MDL": "Moldovan Leu", - "MGA": "Malagasy Ariary", - "MKD": "Macedonian Denar", - "MMK": "Myanmar Kyat", - "MNT": "Mongolian Tögrög", - "MOP": "Macanese Pataca", - "MRO": "Mauritanian Ouguiya", - "MUR": "Mauritian Rupee", - "MVR": "Maldivian Rufiyaa", - "MWK": "Malawian Kwacha", - "MXN": "Mexican Peso", - "MYR": "Malaysian Ringgit", - "MZN": "Mozambican Metical", - "NAD": "Namibian Dollar", - "NGN": "Nigerian Naira", - "NIO": "Nicaraguan Córdoba", - "NOK": "Norwegian Krone", - "NPR": "Nepalese Rupee", - "NZD": "New Zealand Dollar", - "OMR": "Omani Rial", - "PAB": "Panamanian Balboa", - "PEN": "Peruvian Sol", - "PGK": "Papua New Guinean Kina", - "PHP": "Philippine Peso", - "PKR": "Pakistani Rupee", - "PLN": "Polish Złoty", - "PYG": "Paraguayan Guaraní", - "QAR": "Qatari Riyal", - "RON": "Romanian Leu", - "RSD": "Serbian Dinar", - "RUB": "Russian Ruble", - "RWF": "Rwandan Franc", - "SAR": "Saudi Riyal", - "SBD": "Solomon Islands Dollar", - "SCR": "Seychellois Rupee", - "SEK": "Swedish Krona", - "SGD": "Singapore Dollar", - "SHP": "Saint Helenian Pound", - "SLL": "Sierra Leonean Leone", - "SOS": "Somali Shilling", - "SRD": "Surinamese Dollar", - "SSP": "South Sudanese Pound", - "STD": "São Tomé and Príncipe Dobra", - "SVC": "Salvadoran Colón", - "SZL": "Swazi Lilangeni", - "THB": "Thai Baht", - "TJS": "Tajikistani Somoni", - "TMT": "Turkmenistani Manat", - "TND": "Tunisian Dinar", - "TOP": "Tongan Paʻanga", - "TRY": "Turkish Lira", - "TTD": "Trinidad and Tobago Dollar", - "TWD": "New Taiwan Dollar", - "TZS": "Tanzanian Shilling", - "UAH": "Ukrainian Hryvnia", - "UGX": "Ugandan Shilling", - "USD": "US Dollar", - "UYU": "Uruguayan Peso", - "UZS": "Uzbekistan Som", - "VEF": "Venezuelan Bolívar", - "VES": "Venezuelan Bolívar Soberano", - "VND": "Vietnamese Đồng", - "VUV": "Vanuatu Vatu", - "WST": "Samoan Tala", - "XAF": "Central African Cfa Franc", - "XAG": "Silver (Troy Ounce)", - "XAU": "Gold (Troy Ounce)", - "XCD": "East Caribbean Dollar", - "XDR": "Special Drawing Rights", - "XOF": "West African Cfa Franc", - "XPD": "Palladium", - "XPF": "Cfp Franc", - "XPT": "Platinum", - "YER": "Yemeni Rial", - "ZAR": "South African Rand", - "ZMW": "Zambian Kwacha", - "ZWL": "Zimbabwean Dollar" -} \ No newline at end of file diff --git a/lnbits/extensions/bleskomat/helpers.py b/lnbits/extensions/bleskomat/helpers.py deleted file mode 100644 index a3857b77..00000000 --- a/lnbits/extensions/bleskomat/helpers.py +++ /dev/null @@ -1,153 +0,0 @@ -import base64 -import hashlib -import hmac -from http import HTTPStatus -from binascii import unhexlify -from typing import Dict -from quart import url_for -import urllib - - -def generate_bleskomat_lnurl_hash(secret: str): - m = hashlib.sha256() - m.update(f"{secret}".encode()) - return m.hexdigest() - - -def generate_bleskomat_lnurl_signature( - payload: str, api_key_secret: str, api_key_encoding: str = "hex" -): - if api_key_encoding == "hex": - key = unhexlify(api_key_secret) - elif api_key_encoding == "base64": - key = base64.b64decode(api_key_secret) - else: - key = bytes(f"{api_key_secret}") - return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest() - - -def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str): - # The secret is not randomly generated by the server. - # Instead it is the hash of the API key ID and signature concatenated together. - m = hashlib.sha256() - m.update(f"{api_key_id}-{signature}".encode()) - return m.hexdigest() - - -def get_callback_url(): - return url_for("bleskomat.api_bleskomat_lnurl", _external=True) - - -def is_supported_lnurl_subprotocol(tag: str) -> bool: - return tag == "withdrawRequest" - - -class LnurlHttpError(Exception): - def __init__( - self, - message: str = "", - http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR, - ): - self.message = message - self.http_status = http_status - super().__init__(self.message) - - -class LnurlValidationError(Exception): - pass - - -def prepare_lnurl_params(tag: str, query: Dict[str, str]): - params = {} - if not is_supported_lnurl_subprotocol(tag): - raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"') - if tag == "withdrawRequest": - params["minWithdrawable"] = float(query["minWithdrawable"]) - params["maxWithdrawable"] = float(query["maxWithdrawable"]) - params["defaultDescription"] = query["defaultDescription"] - if not params["minWithdrawable"] > 0: - raise LnurlValidationError('"minWithdrawable" must be greater than zero') - if not params["maxWithdrawable"] >= params["minWithdrawable"]: - raise LnurlValidationError( - '"maxWithdrawable" must be greater than or equal to "minWithdrawable"' - ) - return params - - -encode_uri_component_safe_chars = ( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()" -) - - -def query_to_signing_payload(query: Dict[str, str]) -> str: - # Sort the query by key, then stringify it to create the payload. - sorted_keys = sorted(query.keys(), key=str.lower) - payload = [] - for key in sorted_keys: - if not key == "signature": - encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars) - encoded_value = urllib.parse.quote( - query[key], safe=encode_uri_component_safe_chars - ) - payload.append(f"{encoded_key}={encoded_value}") - return "&".join(payload) - - -unshorten_rules = { - "query": {"n": "nonce", "s": "signature", "t": "tag"}, - "tags": { - "c": "channelRequest", - "l": "login", - "p": "payRequest", - "w": "withdrawRequest", - }, - "params": { - "channelRequest": {"pl": "localAmt", "pp": "pushAmt"}, - "login": {}, - "payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"}, - "withdrawRequest": { - "pn": "minWithdrawable", - "px": "maxWithdrawable", - "pd": "defaultDescription", - }, - }, -} - - -def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]: - new_query = {} - rules = unshorten_rules - if "tag" in query: - tag = query["tag"] - elif "t" in query: - tag = query["t"] - else: - raise LnurlValidationError('Missing required query parameter: "tag"') - # Unshorten tag: - if tag in rules["tags"]: - long_tag = rules["tags"][tag] - new_query["tag"] = long_tag - tag = long_tag - if not tag in rules["params"]: - raise LnurlValidationError(f'Unknown tag: "{tag}"') - for key in query: - if key in rules["params"][tag]: - short_param_key = key - long_param_key = rules["params"][tag][short_param_key] - if short_param_key in query: - new_query[long_param_key] = query[short_param_key] - else: - new_query[long_param_key] = query[long_param_key] - elif key in rules["query"]: - # Unshorten general keys: - short_key = key - long_key = rules["query"][short_key] - if not long_key in new_query: - if short_key in query: - new_query[long_key] = query[short_key] - else: - new_query[long_key] = query[long_key] - else: - # Keep unknown key/value pairs unchanged: - new_query[key] = query[key] - return new_query diff --git a/lnbits/extensions/bleskomat/lnurl_api.py b/lnbits/extensions/bleskomat/lnurl_api.py deleted file mode 100644 index 086562d1..00000000 --- a/lnbits/extensions/bleskomat/lnurl_api.py +++ /dev/null @@ -1,134 +0,0 @@ -import json -import math -from quart import jsonify, request -from http import HTTPStatus -import traceback - -from . import bleskomat_ext -from .crud import ( - create_bleskomat_lnurl, - get_bleskomat_by_api_key_id, - get_bleskomat_lnurl, -) - -from .exchange_rates import ( - fetch_fiat_exchange_rate, -) - -from .helpers import ( - generate_bleskomat_lnurl_signature, - generate_bleskomat_lnurl_secret, - LnurlHttpError, - LnurlValidationError, - prepare_lnurl_params, - query_to_signing_payload, - unshorten_lnurl_query, -) - - -# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs. -@bleskomat_ext.route("/u", methods=["GET"]) -async def api_bleskomat_lnurl(): - try: - query = request.args.to_dict() - - # Unshorten query if "s" is used instead of "signature". - if "s" in query: - query = unshorten_lnurl_query(query) - - if "signature" in query: - - # Signature provided. - # Use signature to verify that the URL was generated by an authorized device. - # Later validate parameters, auto-generate LNURL, reply with LNURL response object. - signature = query["signature"] - - # The API key ID, nonce, and tag should be present in the query string. - for field in ["id", "nonce", "tag"]: - if not field in query: - raise LnurlHttpError( - f'Failed API key signature check: Missing "{field}"', - HTTPStatus.BAD_REQUEST, - ) - - # URL signing scheme is described here: - # https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme - payload = query_to_signing_payload(query) - api_key_id = query["id"] - bleskomat = await get_bleskomat_by_api_key_id(api_key_id) - if not bleskomat: - raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST) - api_key_secret = bleskomat.api_key_secret - api_key_encoding = bleskomat.api_key_encoding - expected_signature = generate_bleskomat_lnurl_signature( - payload, api_key_secret, api_key_encoding - ) - if signature != expected_signature: - raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN) - - # Signature is valid. - # In the case of signed URLs, the secret is deterministic based on the API key ID and signature. - secret = generate_bleskomat_lnurl_secret(api_key_id, signature) - lnurl = await get_bleskomat_lnurl(secret) - if not lnurl: - try: - tag = query["tag"] - params = prepare_lnurl_params(tag, query) - if "f" in query: - rate = await fetch_fiat_exchange_rate( - currency=query["f"], - provider=bleskomat.exchange_rate_provider, - ) - # Convert fee (%) to decimal: - fee = float(bleskomat.fee) / 100 - if tag == "withdrawRequest": - for key in ["minWithdrawable", "maxWithdrawable"]: - amount_sats = int( - math.floor((params[key] / rate) * 1e8) - ) - fee_sats = int(math.floor(amount_sats * fee)) - amount_sats_less_fee = amount_sats - fee_sats - # Convert to msats: - params[key] = int(amount_sats_less_fee * 1e3) - except LnurlValidationError as e: - raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST) - # Create a new LNURL using the query parameters provided in the signed URL. - params = json.JSONEncoder().encode(params) - lnurl = await create_bleskomat_lnurl( - bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1 - ) - - # Reply with LNURL response object. - return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK - - # No signature provided. - # Treat as "action" callback. - - if not "k1" in query: - raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST) - - secret = query["k1"] - lnurl = await get_bleskomat_lnurl(secret) - if not lnurl: - raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST) - - if not lnurl.has_uses_remaining(): - raise LnurlHttpError( - "Maximum number of uses already reached", HTTPStatus.BAD_REQUEST - ) - - try: - await lnurl.execute_action(query) - except LnurlValidationError as e: - raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST) - - except LnurlHttpError as e: - return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status - except Exception: - traceback.print_exc() - return ( - jsonify({"status": "ERROR", "reason": "Unexpected error"}), - HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - return jsonify({"status": "OK"}), HTTPStatus.OK diff --git a/lnbits/extensions/bleskomat/migrations.py b/lnbits/extensions/bleskomat/migrations.py deleted file mode 100644 index 84e886e5..00000000 --- a/lnbits/extensions/bleskomat/migrations.py +++ /dev/null @@ -1,37 +0,0 @@ -async def m001_initial(db): - - await db.execute( - """ - CREATE TABLE bleskomat.bleskomats ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - api_key_id TEXT NOT NULL, - api_key_secret TEXT NOT NULL, - api_key_encoding TEXT NOT NULL, - name TEXT NOT NULL, - fiat_currency TEXT NOT NULL, - exchange_rate_provider TEXT NOT NULL, - fee TEXT NOT NULL, - UNIQUE(api_key_id) - ); - """ - ) - - await db.execute( - """ - CREATE TABLE bleskomat.bleskomat_lnurls ( - id TEXT PRIMARY KEY, - bleskomat TEXT NOT NULL, - wallet TEXT NOT NULL, - hash TEXT NOT NULL, - tag TEXT NOT NULL, - params TEXT NOT NULL, - api_key_id TEXT NOT NULL, - initial_uses INTEGER DEFAULT 1, - remaining_uses INTEGER DEFAULT 0, - created_time INTEGER, - updated_time INTEGER, - UNIQUE(hash) - ); - """ - ) diff --git a/lnbits/extensions/bleskomat/models.py b/lnbits/extensions/bleskomat/models.py deleted file mode 100644 index 216f83c6..00000000 --- a/lnbits/extensions/bleskomat/models.py +++ /dev/null @@ -1,110 +0,0 @@ -import json -import time -from typing import NamedTuple, Dict -from lnbits import bolt11 -from lnbits.core.services import pay_invoice -from . import db -from .helpers import get_callback_url, LnurlValidationError - - -class Bleskomat(NamedTuple): - id: str - wallet: str - api_key_id: str - api_key_secret: str - api_key_encoding: str - name: str - fiat_currency: str - exchange_rate_provider: str - fee: str - - -class BleskomatLnurl(NamedTuple): - id: str - bleskomat: str - wallet: str - hash: str - tag: str - params: str - api_key_id: str - initial_uses: int - remaining_uses: int - created_time: int - updated_time: int - - def has_uses_remaining(self) -> bool: - # When initial uses is 0 then the LNURL has unlimited uses. - return self.initial_uses == 0 or self.remaining_uses > 0 - - def get_info_response_object(self, secret: str) -> Dict[str, str]: - tag = self.tag - params = json.loads(self.params) - response = {"tag": tag} - if tag == "withdrawRequest": - for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]: - response[key] = params[key] - response["callback"] = get_callback_url() - response["k1"] = secret - return response - - def validate_action(self, query: Dict[str, str]) -> None: - tag = self.tag - params = json.loads(self.params) - # Perform tag-specific checks. - if tag == "withdrawRequest": - for field in ["pr"]: - if not field in query: - raise LnurlValidationError(f'Missing required parameter: "{field}"') - # Check the bolt11 invoice(s) provided. - pr = query["pr"] - if "," in pr: - raise LnurlValidationError("Multiple payment requests not supported") - try: - invoice = bolt11.decode(pr) - except ValueError: - raise LnurlValidationError( - 'Invalid parameter ("pr"): Lightning payment request expected' - ) - if invoice.amount_msat < params["minWithdrawable"]: - raise LnurlValidationError( - 'Amount in invoice must be greater than or equal to "minWithdrawable"' - ) - if invoice.amount_msat > params["maxWithdrawable"]: - raise LnurlValidationError( - 'Amount in invoice must be less than or equal to "maxWithdrawable"' - ) - else: - raise LnurlValidationError(f'Unknown subprotocol: "{tag}"') - - async def execute_action(self, query: Dict[str, str]): - self.validate_action(query) - used = False - async with db.connect() as conn: - if self.initial_uses > 0: - used = await self.use(conn) - if not used: - raise LnurlValidationError("Maximum number of uses already reached") - tag = self.tag - if tag == "withdrawRequest": - try: - payment_hash = await pay_invoice( - wallet_id=self.wallet, - payment_request=query["pr"], - ) - except Exception: - raise LnurlValidationError("Failed to pay invoice") - if not payment_hash: - raise LnurlValidationError("Failed to pay invoice") - - async def use(self, conn) -> bool: - now = int(time.time()) - result = await conn.execute( - """ - UPDATE bleskomat.bleskomat_lnurls - SET remaining_uses = remaining_uses - 1, updated_time = ? - WHERE id = ? - AND remaining_uses > 0 - """, - (now, self.id), - ) - return result.rowcount > 0 diff --git a/lnbits/extensions/bleskomat/static/js/index.js b/lnbits/extensions/bleskomat/static/js/index.js deleted file mode 100644 index fd166ff3..00000000 --- a/lnbits/extensions/bleskomat/static/js/index.js +++ /dev/null @@ -1,216 +0,0 @@ -/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ - -Vue.component(VueQrcode.name, VueQrcode) - -var mapBleskomat = function (obj) { - obj._data = _.clone(obj) - return obj -} - -var defaultValues = { - name: 'My Bleskomat', - fiat_currency: 'EUR', - exchange_rate_provider: 'coinbase', - fee: '0.00' -} - -new Vue({ - el: '#vue', - mixins: [windowMixin], - data: function () { - return { - checker: null, - bleskomats: [], - bleskomatsTable: { - columns: [ - { - name: 'api_key_id', - align: 'left', - label: 'API Key ID', - field: 'api_key_id' - }, - { - name: 'name', - align: 'left', - label: 'Name', - field: 'name' - }, - { - name: 'fiat_currency', - align: 'left', - label: 'Fiat Currency', - field: 'fiat_currency' - }, - { - name: 'exchange_rate_provider', - align: 'left', - label: 'Exchange Rate Provider', - field: 'exchange_rate_provider' - }, - { - name: 'fee', - align: 'left', - label: 'Fee (%)', - field: 'fee' - } - ], - pagination: { - rowsPerPage: 10 - } - }, - formDialog: { - show: false, - fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies), - exchangeRateProviders: _.keys( - window.bleskomat_vars.exchange_rate_providers - ), - data: _.clone(defaultValues) - } - } - }, - computed: { - sortedBleskomats: function () { - return this.bleskomats.sort(function (a, b) { - // Sort by API Key ID alphabetically. - var apiKeyId_A = a.api_key_id.toLowerCase() - var apiKeyId_B = b.api_key_id.toLowerCase() - return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0 - }) - } - }, - methods: { - getBleskomats: function () { - var self = this - LNbits.api - .request( - 'GET', - '/bleskomat/api/v1/bleskomats?all_wallets', - this.g.user.wallets[0].adminkey - ) - .then(function (response) { - self.bleskomats = response.data.map(function (obj) { - return mapBleskomat(obj) - }) - }) - .catch(function (error) { - clearInterval(self.checker) - LNbits.utils.notifyApiError(error) - }) - }, - closeFormDialog: function () { - this.formDialog.data = _.clone(defaultValues) - }, - exportConfigFile: function (bleskomatId) { - var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) - var fieldToKey = { - api_key_id: 'apiKey.id', - api_key_secret: 'apiKey.key', - api_key_encoding: 'apiKey.encoding', - fiat_currency: 'fiatCurrency' - } - var lines = _.chain(bleskomat) - .map(function (value, field) { - var key = fieldToKey[field] || null - return key ? [key, value].join('=') : null - }) - .compact() - .value() - lines.push('callbackUrl=' + window.bleskomat_vars.callback_url) - lines.push('shorten=true') - var content = lines.join('\n') - var status = Quasar.utils.exportFile( - 'bleskomat.conf', - content, - 'text/plain' - ) - if (status !== true) { - Quasar.plugins.Notify.create({ - message: 'Browser denied file download...', - color: 'negative', - icon: null - }) - } - }, - openUpdateDialog: function (bleskomatId) { - var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) - this.formDialog.data = _.clone(bleskomat._data) - this.formDialog.show = true - }, - sendFormData: function () { - var wallet = _.findWhere(this.g.user.wallets, { - id: this.formDialog.data.wallet - }) - var data = _.omit(this.formDialog.data, 'wallet') - if (data.id) { - this.updateBleskomat(wallet, data) - } else { - this.createBleskomat(wallet, data) - } - }, - updateBleskomat: function (wallet, data) { - var self = this - LNbits.api - .request( - 'PUT', - '/bleskomat/api/v1/bleskomat/' + data.id, - wallet.adminkey, - _.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee') - ) - .then(function (response) { - self.bleskomats = _.reject(self.bleskomats, function (obj) { - return obj.id === data.id - }) - self.bleskomats.push(mapBleskomat(response.data)) - self.formDialog.show = false - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - }, - createBleskomat: function (wallet, data) { - var self = this - LNbits.api - .request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data) - .then(function (response) { - self.bleskomats.push(mapBleskomat(response.data)) - self.formDialog.show = false - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - }, - deleteBleskomat: function (bleskomatId) { - var self = this - var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) - LNbits.utils - .confirmDialog( - 'Are you sure you want to delete "' + bleskomat.name + '"?' - ) - .onOk(function () { - LNbits.api - .request( - 'DELETE', - '/bleskomat/api/v1/bleskomat/' + bleskomatId, - _.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey - ) - .then(function (response) { - self.bleskomats = _.reject(self.bleskomats, function (obj) { - return obj.id === bleskomatId - }) - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - }) - } - }, - created: function () { - if (this.g.user.wallets.length) { - var getBleskomats = this.getBleskomats - getBleskomats() - this.checker = setInterval(function () { - getBleskomats() - }, 20000) - } - } -}) diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html deleted file mode 100644 index 210d534c..00000000 --- a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html +++ /dev/null @@ -1,65 +0,0 @@ - - - -

- This extension allows you to connect a Bleskomat ATM to an lnbits - wallet. It will work with both the - open-source DIY Bleskomat ATM project - as well as the - commercial Bleskomat ATM. -

-
Connect Your Bleskomat ATM
-
-
    -
  1. Click the "Add Bleskomat" button on this page to begin.
  2. -
  3. - Choose a wallet. This will be the wallet that is used to pay - satoshis to your ATM customers. -
  4. -
  5. - Choose the fiat currency. This should match the fiat currency that - your ATM accepts. -
  6. -
  7. - Pick an exchange rate provider. This is the API that will be used to - query the fiat to satoshi exchange rate at the time your customer - attempts to withdraw their funds. -
  8. -
  9. Set your ATM's fee percentage.
  10. -
  11. Click the "Done" button.
  12. -
  13. - Find the new Bleskomat in the list and then click the export icon to - download a new configuration file for your ATM. -
  14. -
  15. - Copy the configuration file ("bleskomat.conf") to your ATM's SD - card. -
  16. -
  17. - Restart Your Bleskomat ATM. It should automatically reload the - configurations from the SD card. -
  18. -
-
-
How does it work?
-

- Since the Bleskomat ATMs are designed to be offline, a cryptographic - signing scheme is used to verify that the URL was generated by an - authorized device. When one of your customers inserts fiat money into - the device, a signed URL (lnurl-withdraw) is created and displayed as a - QR code. Your customer scans the QR code with their lnurl-supporting - mobile app, their mobile app communicates with the web API of lnbits to - verify the signature, the fiat currency amount is converted to sats, the - customer accepts the withdrawal, and finally lnbits will pay the - customer from your lnbits wallet. -

-
-
-
diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/index.html b/lnbits/extensions/bleskomat/templates/bleskomat/index.html deleted file mode 100644 index 0cc51237..00000000 --- a/lnbits/extensions/bleskomat/templates/bleskomat/index.html +++ /dev/null @@ -1,180 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block scripts %} {{ window_vars(user) }} - - -{% endblock %} {% block page %} -
-
- - - Add Bleskomat - - - - - -
-
-
Bleskomats
-
-
- - {% raw %} - - - {% endraw %} - -
-
-
- -
- - -
- {{SITE_TITLE}} Bleskomat extension -
-
- - - {% include "bleskomat/_api_docs.html" %} - -
-
- - - - - - - - - - - - -
- Update Bleskomat - Add Bleskomat - Cancel -
-
-
-
-
-{% endblock %} diff --git a/lnbits/extensions/bleskomat/views.py b/lnbits/extensions/bleskomat/views.py deleted file mode 100644 index 3a7f7263..00000000 --- a/lnbits/extensions/bleskomat/views.py +++ /dev/null @@ -1,22 +0,0 @@ -from quart import g, render_template - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import bleskomat_ext - -from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies -from .helpers import get_callback_url - - -@bleskomat_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - bleskomat_vars = { - "callback_url": get_callback_url(), - "exchange_rate_providers": exchange_rate_providers_serializable, - "fiat_currencies": fiat_currencies, - } - return await render_template( - "bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars - ) diff --git a/lnbits/extensions/bleskomat/views_api.py b/lnbits/extensions/bleskomat/views_api.py deleted file mode 100644 index 2971b066..00000000 --- a/lnbits/extensions/bleskomat/views_api.py +++ /dev/null @@ -1,120 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus - -from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import bleskomat_ext -from .crud import ( - create_bleskomat, - get_bleskomat, - get_bleskomats, - update_bleskomat, - delete_bleskomat, -) - -from .exchange_rates import ( - exchange_rate_providers, - fetch_fiat_exchange_rate, - fiat_currencies, -) - - -@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"]) -@api_check_wallet_key("admin") -async def api_bleskomats(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify( - [bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)] - ), - HTTPStatus.OK, - ) - - -@bleskomat_ext.route("/api/v1/bleskomat/", methods=["GET"]) -@api_check_wallet_key("admin") -async def api_bleskomat_retrieve(bleskomat_id): - bleskomat = await get_bleskomat(bleskomat_id) - - if not bleskomat or bleskomat.wallet != g.wallet.id: - return ( - jsonify({"message": "Bleskomat configuration not found."}), - HTTPStatus.NOT_FOUND, - ) - - return jsonify(bleskomat._asdict()), HTTPStatus.OK - - -@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"]) -@bleskomat_ext.route("/api/v1/bleskomat/", methods=["PUT"]) -@api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "name": {"type": "string", "empty": False, "required": True}, - "fiat_currency": { - "type": "string", - "allowed": fiat_currencies.keys(), - "required": True, - }, - "exchange_rate_provider": { - "type": "string", - "allowed": exchange_rate_providers.keys(), - "required": True, - }, - "fee": {"type": ["string", "float", "number", "integer"], "required": True}, - } -) -async def api_bleskomat_create_or_update(bleskomat_id=None): - try: - fiat_currency = g.data["fiat_currency"] - exchange_rate_provider = g.data["exchange_rate_provider"] - await fetch_fiat_exchange_rate( - currency=fiat_currency, provider=exchange_rate_provider - ) - except Exception as e: - print(e) - return ( - jsonify( - { - "message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"' - } - ), - HTTPStatus.INTERNAL_SERVER_ERROR, - ) - - if bleskomat_id: - bleskomat = await get_bleskomat(bleskomat_id) - if not bleskomat or bleskomat.wallet != g.wallet.id: - return ( - jsonify({"message": "Bleskomat configuration not found."}), - HTTPStatus.NOT_FOUND, - ) - bleskomat = await update_bleskomat(bleskomat_id, **g.data) - else: - bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data) - - return ( - jsonify(bleskomat._asdict()), - HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED, - ) - - -@bleskomat_ext.route("/api/v1/bleskomat/", methods=["DELETE"]) -@api_check_wallet_key("admin") -async def api_bleskomat_delete(bleskomat_id): - bleskomat = await get_bleskomat(bleskomat_id) - - if not bleskomat or bleskomat.wallet != g.wallet.id: - return ( - jsonify({"message": "Bleskomat configuration not found."}), - HTTPStatus.NOT_FOUND, - ) - - await delete_bleskomat(bleskomat_id) - - return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/captcha/README.md b/lnbits/extensions/captcha/README.md deleted file mode 100644 index 27729459..00000000 --- a/lnbits/extensions/captcha/README.md +++ /dev/null @@ -1,11 +0,0 @@ -

Example Extension

-

*tagline*

-This is an example extension to help you organise and build you own. - -Try to include an image - - - -

If your extension has API endpoints, include useful ones here

- -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" diff --git a/lnbits/extensions/captcha/__init__.py b/lnbits/extensions/captcha/__init__.py deleted file mode 100644 index f25dccce..00000000 --- a/lnbits/extensions/captcha/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_captcha") - -captcha_ext: Blueprint = Blueprint( - "captcha", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/captcha/config.json b/lnbits/extensions/captcha/config.json deleted file mode 100644 index 4ef7c43f..00000000 --- a/lnbits/extensions/captcha/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Captcha", - "short_description": "Create captcha to stop spam", - "icon": "block", - "contributors": ["pseudozach"] -} diff --git a/lnbits/extensions/captcha/crud.py b/lnbits/extensions/captcha/crud.py deleted file mode 100644 index 43a0374e..00000000 --- a/lnbits/extensions/captcha/crud.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import List, Optional, Union - -from lnbits.helpers import urlsafe_short_hash - -from . import db -from .models import Captcha - - -async def create_captcha( - *, - wallet_id: str, - url: str, - memo: str, - description: Optional[str] = None, - amount: int = 0, - remembers: bool = True, -) -> Captcha: - captcha_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO captcha.captchas (id, wallet, url, memo, description, amount, remembers) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - (captcha_id, wallet_id, url, memo, description, amount, int(remembers)), - ) - - captcha = await get_captcha(captcha_id) - assert captcha, "Newly created captcha couldn't be retrieved" - return captcha - - -async def get_captcha(captcha_id: str) -> Optional[Captcha]: - row = await db.fetchone( - "SELECT * FROM captcha.captchas WHERE id = ?", (captcha_id,) - ) - - return Captcha.from_row(row) if row else None - - -async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM captcha.captchas WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [Captcha.from_row(row) for row in rows] - - -async def delete_captcha(captcha_id: str) -> None: - await db.execute("DELETE FROM captcha.captchas WHERE id = ?", (captcha_id,)) diff --git a/lnbits/extensions/captcha/migrations.py b/lnbits/extensions/captcha/migrations.py deleted file mode 100644 index 744fc506..00000000 --- a/lnbits/extensions/captcha/migrations.py +++ /dev/null @@ -1,63 +0,0 @@ -async def m001_initial(db): - """ - Initial captchas table. - """ - await db.execute( - """ - CREATE TABLE captcha.captchas ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - secret TEXT NOT NULL, - url TEXT NOT NULL, - memo TEXT NOT NULL, - amount INTEGER NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - -async def m002_redux(db): - """ - Creates an improved captchas table and migrates the existing data. - """ - await db.execute("ALTER TABLE captcha.captchas RENAME TO captchas_old") - await db.execute( - """ - CREATE TABLE captcha.captchas ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - url TEXT NOT NULL, - memo TEXT NOT NULL, - description TEXT NULL, - amount INTEGER DEFAULT 0, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """, - remembers INTEGER DEFAULT 0, - extras TEXT NULL - ); - """ - ) - - for row in [ - list(row) for row in await db.fetchall("SELECT * FROM captcha.captchas_old") - ]: - await db.execute( - """ - INSERT INTO captcha.captchas ( - id, - wallet, - url, - memo, - amount, - time - ) - VALUES (?, ?, ?, ?, ?, ?) - """, - (row[0], row[1], row[3], row[4], row[5], row[6]), - ) - - await db.execute("DROP TABLE captcha.captchas_old") diff --git a/lnbits/extensions/captcha/models.py b/lnbits/extensions/captcha/models.py deleted file mode 100644 index 3179d5c1..00000000 --- a/lnbits/extensions/captcha/models.py +++ /dev/null @@ -1,23 +0,0 @@ -import json - -from sqlite3 import Row -from typing import NamedTuple, Optional - - -class Captcha(NamedTuple): - id: str - wallet: str - url: str - memo: str - description: str - amount: int - time: int - remembers: bool - extras: Optional[dict] - - @classmethod - def from_row(cls, row: Row) -> "Captcha": - data = dict(row) - data["remembers"] = bool(data["remembers"]) - data["extras"] = json.loads(data["extras"]) if data["extras"] else None - return cls(**data) diff --git a/lnbits/extensions/captcha/static/js/captcha.js b/lnbits/extensions/captcha/static/js/captcha.js deleted file mode 100644 index 1da24f57..00000000 --- a/lnbits/extensions/captcha/static/js/captcha.js +++ /dev/null @@ -1,82 +0,0 @@ -var ciframeLoaded = !1, - captchaStyleAdded = !1 - -function ccreateIframeElement(t = {}) { - const e = document.createElement('iframe') - // e.style.marginLeft = "25px", - ;(e.style.border = 'none'), - (e.style.width = '100%'), - (e.style.height = '100%'), - (e.scrolling = 'no'), - (e.id = 'captcha-iframe') - t.dest, t.amount, t.currency, t.label, t.opReturn - var captchaid = document - .getElementById('captchascript') - .getAttribute('data-captchaid') - var lnbhostsrc = document.getElementById('captchascript').getAttribute('src') - var lnbhost = lnbhostsrc.split('/captcha/static/js/captcha.js')[0] - return (e.src = lnbhost + '/captcha/' + captchaid), e -} -document.addEventListener('DOMContentLoaded', function () { - if (captchaStyleAdded) console.log('Captcha already added!') - else { - console.log('Adding captcha'), (captchaStyleAdded = !0) - var t = document.createElement('style') - t.innerHTML = - "\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}" - var e = document.querySelector('script') - e.parentNode.insertBefore(t, e) - var i = document.getElementById('captchacheckbox'), - n = i.dataset, - o = 'true' === n.dark - var a = document.createElement('div') - ;(a.className += ' modal-captcha-container'), - (a.innerHTML = - '\t\t\t'), - document.getElementsByTagName('body')[0].appendChild(a) - var r = document.getElementsByClassName('modal-captcha-content').item(0) - document - .getElementsByClassName('close-button-captcha') - .item(0) - .addEventListener('click', d), - window.addEventListener('click', function (t) { - t.target === a && d() - }), - i.addEventListener('change', function () { - if (this.checked) { - // console.log("checkbox checked"); - if (0 == ciframeLoaded) { - // console.log("n: ", n); - var t = ccreateIframeElement(n) - r.appendChild(t), (ciframeLoaded = !0) - } - d() - } - }) - } - - function d() { - a.classList.toggle('show-modal-captcha') - } -}) - -function receiveMessage(event) { - if (event.data.includes('paymenthash')) { - // console.log("paymenthash received: ", event.data); - document.getElementById('captchapayhash').value = event.data.split('_')[1] - } - if (event.data.includes('removetheiframe')) { - if (event.data.includes('nok')) { - //invoice was NOT paid - // console.log("receiveMessage not paid") - document.getElementById('captchacheckbox').checked = false - } - ciframeLoaded = !1 - var element = document.getElementById('captcha-iframe') - document - .getElementsByClassName('modal-captcha-container')[0] - .classList.toggle('show-modal-captcha') - element.parentNode.removeChild(element) - } -} -window.addEventListener('message', receiveMessage, false) diff --git a/lnbits/extensions/captcha/templates/captcha/_api_docs.html b/lnbits/extensions/captcha/templates/captcha/_api_docs.html deleted file mode 100644 index dfe2f32f..00000000 --- a/lnbits/extensions/captcha/templates/captcha/_api_docs.html +++ /dev/null @@ -1,147 +0,0 @@ - - - - - GET /captcha/api/v1/captchas -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [<captcha_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}captcha/api/v1/captchas -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - POST /captcha/api/v1/captchas -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
- {"amount": <integer>, "description": <string>, "memo": - <string>, "remembers": <boolean>, "url": - <string>} -
- Returns 201 CREATED (application/json) -
- {"amount": <integer>, "description": <string>, "id": - <string>, "memo": <string>, "remembers": <boolean>, - "time": <int>, "url": <string>, "wallet": - <string>} -
Curl example
- curl -X POST {{ request.url_root }}captcha/api/v1/captchas -d - '{"url": <string>, "memo": <string>, "description": - <string>, "amount": <integer>, "remembers": - <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: - {{ g.user.wallets[0].adminkey }}" - -
-
-
- - - - POST - /captcha/api/v1/captchas/<captcha_id>/invoice -
Body (application/json)
- {"amount": <integer>} -
- Returns 201 CREATED (application/json) -
- {"payment_hash": <string>, "payment_request": - <string>} -
Curl example
- curl -X POST {{ request.url_root - }}captcha/api/v1/captchas/<captcha_id>/invoice -d '{"amount": - <integer>}' -H "Content-type: application/json" - -
-
-
- - - - POST - /captcha/api/v1/captchas/<captcha_id>/check_invoice -
Body (application/json)
- {"payment_hash": <string>} -
- Returns 200 OK (application/json) -
- {"paid": false}
- {"paid": true, "url": <string>, "remembers": - <boolean>} -
Curl example
- curl -X POST {{ request.url_root - }}captcha/api/v1/captchas/<captcha_id>/check_invoice -d - '{"payment_hash": <string>}' -H "Content-type: application/json" - -
-
-
- - - - DELETE - /captcha/api/v1/captchas/<captcha_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.url_root - }}captcha/api/v1/captchas/<captcha_id> -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - -
-
-
-
diff --git a/lnbits/extensions/captcha/templates/captcha/display.html b/lnbits/extensions/captcha/templates/captcha/display.html deleted file mode 100644 index a96cae05..00000000 --- a/lnbits/extensions/captcha/templates/captcha/display.html +++ /dev/null @@ -1,178 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
{{ captcha.memo }}
- {% if captcha.description %} -

{{ captcha.description }}

- {% endif %} -
- - - - - -
- - - - - -
- Copy invoice - Cancel -
-
-
-
- -

- Captcha accepted. You are probably human.
- -

- -
-
-
-
-
-{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/captcha/templates/captcha/index.html b/lnbits/extensions/captcha/templates/captcha/index.html deleted file mode 100644 index 45318f08..00000000 --- a/lnbits/extensions/captcha/templates/captcha/index.html +++ /dev/null @@ -1,427 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New captcha - - - - - -
-
-
Captchas
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
- -
- - -
- {{SITE_TITLE}} captcha extension -
-
- - - {% include "captcha/_api_docs.html" %} - -
-
- - - - - - - - - - - - - - - - - Remember payments - A succesful payment will be registered in the browser's - storage, so the user doesn't need to pay again to prove they are - human. - - - -
- Create captcha - Cancel -
-
-
-
- - - - {% raw %} - - - - {{ qrCodeDialog.data.snippet }} - -

- Copy the snippet above and paste into your website/form. The checkbox - can be in checked state only after user pays. -

-
-

- ID: {{ qrCodeDialog.data.id }}
- Amount: {{ qrCodeDialog.data.amount }}
- -

- {% endraw %} -
- Copy Snippet - - Close -
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/captcha/views.py b/lnbits/extensions/captcha/views.py deleted file mode 100644 index 2b3643fa..00000000 --- a/lnbits/extensions/captcha/views.py +++ /dev/null @@ -1,22 +0,0 @@ -from quart import g, abort, render_template -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import captcha_ext -from .crud import get_captcha - - -@captcha_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("captcha/index.html", user=g.user) - - -@captcha_ext.route("/") -async def display(captcha_id): - captcha = await get_captcha(captcha_id) or abort( - HTTPStatus.NOT_FOUND, "captcha does not exist." - ) - return await render_template("captcha/display.html", captcha=captcha) diff --git a/lnbits/extensions/captcha/views_api.py b/lnbits/extensions/captcha/views_api.py deleted file mode 100644 index c1b5ade8..00000000 --- a/lnbits/extensions/captcha/views_api.py +++ /dev/null @@ -1,121 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus - -from lnbits.core.crud import get_user, get_wallet -from lnbits.core.services import create_invoice, check_invoice_status -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import captcha_ext -from .crud import create_captcha, get_captcha, get_captchas, delete_captcha - - -@captcha_ext.route("/api/v1/captchas", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_captchas(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]), - HTTPStatus.OK, - ) - - -@captcha_ext.route("/api/v1/captchas", methods=["POST"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "url": {"type": "string", "empty": False, "required": True}, - "memo": {"type": "string", "empty": False, "required": True}, - "description": { - "type": "string", - "empty": True, - "nullable": True, - "required": False, - }, - "amount": {"type": "integer", "min": 0, "required": True}, - "remembers": {"type": "boolean", "required": True}, - } -) -async def api_captcha_create(): - captcha = await create_captcha(wallet_id=g.wallet.id, **g.data) - return jsonify(captcha._asdict()), HTTPStatus.CREATED - - -@captcha_ext.route("/api/v1/captchas/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_captcha_delete(captcha_id): - captcha = await get_captcha(captcha_id) - - if not captcha: - return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND - - if captcha.wallet != g.wallet.id: - return jsonify({"message": "Not your captcha."}), HTTPStatus.FORBIDDEN - - await delete_captcha(captcha_id) - - return "", HTTPStatus.NO_CONTENT - - -@captcha_ext.route("/api/v1/captchas//invoice", methods=["POST"]) -@api_validate_post_request( - schema={"amount": {"type": "integer", "min": 1, "required": True}} -) -async def api_captcha_create_invoice(captcha_id): - captcha = await get_captcha(captcha_id) - - if g.data["amount"] < captcha.amount: - return ( - jsonify({"message": f"Minimum amount is {captcha.amount} sat."}), - HTTPStatus.BAD_REQUEST, - ) - - try: - amount = ( - g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount - ) - payment_hash, payment_request = await create_invoice( - wallet_id=captcha.wallet, - amount=amount, - memo=f"{captcha.memo}", - extra={"tag": "captcha"}, - ) - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - - return ( - jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), - HTTPStatus.CREATED, - ) - - -@captcha_ext.route("/api/v1/captchas//check_invoice", methods=["POST"]) -@api_validate_post_request( - schema={"payment_hash": {"type": "string", "empty": False, "required": True}} -) -async def api_paywal_check_invoice(captcha_id): - captcha = await get_captcha(captcha_id) - - if not captcha: - return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND - - try: - status = await check_invoice_status(captcha.wallet, g.data["payment_hash"]) - is_paid = not status.pending - except Exception: - return jsonify({"paid": False}), HTTPStatus.OK - - if is_paid: - wallet = await get_wallet(captcha.wallet) - payment = await wallet.get_payment(g.data["payment_hash"]) - await payment.set_pending(False) - - return ( - jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}), - HTTPStatus.OK, - ) - - return jsonify({"paid": False}), HTTPStatus.OK diff --git a/lnbits/extensions/diagonalley/README.md b/lnbits/extensions/diagonalley/README.md deleted file mode 100644 index 6ba653e7..00000000 --- a/lnbits/extensions/diagonalley/README.md +++ /dev/null @@ -1,10 +0,0 @@ -

Diagon Alley

-

A movable market stand

-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. - - - -

API endpoints

- -curl -X GET http://YOUR-TOR-ADDRESS diff --git a/lnbits/extensions/diagonalley/__init__.py b/lnbits/extensions/diagonalley/__init__.py deleted file mode 100644 index ac907f5c..00000000 --- a/lnbits/extensions/diagonalley/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from quart import Blueprint - - -diagonalley_ext: Blueprint = Blueprint( - "diagonalley", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/diagonalley/config.json.example b/lnbits/extensions/diagonalley/config.json.example deleted file mode 100644 index 057d0f23..00000000 --- a/lnbits/extensions/diagonalley/config.json.example +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Diagon Alley", - "short_description": "Movable anonymous market stand", - "icon": "add_shopping_cart", - "contributors": ["benarc"] -} diff --git a/lnbits/extensions/diagonalley/crud.py b/lnbits/extensions/diagonalley/crud.py deleted file mode 100644 index 971cd449..00000000 --- a/lnbits/extensions/diagonalley/crud.py +++ /dev/null @@ -1,308 +0,0 @@ -from base64 import urlsafe_b64encode -from uuid import uuid4 -from typing import List, Optional, Union -import httpx -from lnbits.db import open_ext_db -from lnbits.settings import WALLET -from .models import Products, Orders, Indexers -import re - -regex = re.compile( - r"^(?:http|ftp)s?://" # http:// or https:// - r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|" - r"localhost|" - r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" - r"(?::\d+)?" - r"(?:/?|[/?]\S+)$", - re.IGNORECASE, -) - -###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 diagonalley.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 diagonalley.products SET {q} WHERE id = ?", - (*kwargs.values(), product_id), - ) - row = db.fetchone( - "SELECT * FROM diagonalley.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 diagonalley.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 diagonalley.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 diagonalley.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 diagonalley.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 diagonalley.indexers SET {q} WHERE id = ?", - (*kwargs.values(), indexer_id), - ) - row = db.fetchone( - "SELECT * FROM diagonalley.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 diagonalley.indexers WHERE id = ?", (indexer_id,) - ) - try: - x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"]) - if x.status_code == 200: - print(x) - print("poo") - with open_ext_db("diagonalley") as db: - db.execute( - "UPDATE diagonalley.indexers SET online = ? WHERE id = ?", - ( - True, - indexer_id, - ), - ) - else: - with open_ext_db("diagonalley") as db: - db.execute( - "UPDATE diagonalley.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 diagonalley.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 diagonalley.indexers WHERE wallet IN ({q})", (*wallet_ids,) - ) - - for r in rows: - try: - x = httpx.get(r["indexeraddress"] + "/" + r["ratingkey"]) - if x.status_code == 200: - with open_ext_db("diagonalley") as db: - db.execute( - "UPDATE diagonalley.indexers SET online = ? WHERE id = ?", - ( - True, - r["id"], - ), - ) - else: - with open_ext_db("diagonalley") as db: - db.execute( - "UPDATE diagonalley.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 diagonalley.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 diagonalley.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 diagonalley.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 diagonalley.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 diagonalley.orders WHERE wallet IN ({q})", (*wallet_ids,) - ) - for r in rows: - PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid - if PAID: - with open_ext_db("diagonalley") as db: - db.execute( - "UPDATE diagonalley.orders SET paid = ? WHERE id = ?", - ( - True, - r["id"], - ), - ) - rows = db.fetchall( - f"SELECT * FROM diagonalley.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 diagonalley.orders WHERE id = ?", (order_id,)) diff --git a/lnbits/extensions/diagonalley/migrations.py b/lnbits/extensions/diagonalley/migrations.py deleted file mode 100644 index 9f2b787f..00000000 --- a/lnbits/extensions/diagonalley/migrations.py +++ /dev/null @@ -1,60 +0,0 @@ -async def m001_initial(db): - """ - Initial products table. - """ - await db.execute( - """ - CREATE TABLE diagonalley.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. - """ - await db.execute( - """ - CREATE TABLE diagonalley.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. - """ - await db.execute( - """ - CREATE TABLE diagonalley.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 - ); - """ - ) diff --git a/lnbits/extensions/diagonalley/models.py b/lnbits/extensions/diagonalley/models.py deleted file mode 100644 index 08e15969..00000000 --- a/lnbits/extensions/diagonalley/models.py +++ /dev/null @@ -1,40 +0,0 @@ -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 diff --git a/lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html b/lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html deleted file mode 100644 index 585e8d7c..00000000 --- a/lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html +++ /dev/null @@ -1,122 +0,0 @@ - - - -
- Diagon Alley: Decentralised Market-Stalls -
-

- 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.
- More information on the - Diagon Alley Protocol
- - Created by, Ben Arc -

-
-
-
- - - - - GET - /api/v1/diagonalley/stall/products/<indexer_id> -
Body (application/json)
-
- Returns 201 CREATED (application/json) -
- Product JSON list -
Curl example
- curl -X GET {{ request.url_root - }}diagonalley/api/v1/diagonalley/stall/products/<indexer_id> -
-
-
- - - - POST - /api/v1/diagonalley/stall/order/<indexer_id> -
Body (application/json)
- {"id": <string>, "address": <string>, "shippingzone": - <integer>, "email": <string>, "quantity": - <integer>} -
- Returns 201 CREATED (application/json) -
- {"checking_id": <string>,"payment_request": - <string>} -
Curl example
- curl -X POST {{ request.url_root - }}diagonalley/api/v1/diagonalley/stall/order/<indexer_id> -d - '{"id": <product_id&>, "email": <customer_email>, - "address": <customer_address>, "quantity": 2, "shippingzone": - 1}' -H "Content-type: application/json" - -
-
-
- - - - GET - /diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id> -
Headers
-
- Returns 200 OK (application/json) -
- {"shipped": <boolean>} -
Curl example
- curl -X GET {{ request.url_root - }}diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id> - -H "Content-type: application/json" -
-
-
-
diff --git a/lnbits/extensions/diagonalley/templates/diagonalley/index.html b/lnbits/extensions/diagonalley/templates/diagonalley/index.html deleted file mode 100644 index c041239f..00000000 --- a/lnbits/extensions/diagonalley/templates/diagonalley/index.html +++ /dev/null @@ -1,906 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New Product - New Indexer - - Frontend shop your stall will list its products in - - - - - - -
-
-
Products
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
- - - -
-
-
Indexers
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
- - - -
-
-
Orders
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
- -
- - -
LNbits Diagon Alley Extension
-
- - - {% include "diagonalley/_api_docs.html" %} - -
-
- - - - - - - - - - - - - -
- Update Product - - Create Product - - Cancel -
-
-
-
- - - - - - - - - - - - - - - - -
- Update Indexer - - Create Indexer - - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/diagonalley/templates/diagonalley/stall.html b/lnbits/extensions/diagonalley/templates/diagonalley/stall.html deleted file mode 100644 index a45d254d..00000000 --- a/lnbits/extensions/diagonalley/templates/diagonalley/stall.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/lnbits/extensions/diagonalley/views.py b/lnbits/extensions/diagonalley/views.py deleted file mode 100644 index 6781a99e..00000000 --- a/lnbits/extensions/diagonalley/views.py +++ /dev/null @@ -1,11 +0,0 @@ -from quart import g, render_template - -from lnbits.decorators import check_user_exists, validate_uuids -from lnbits.extensions.diagonalley import diagonalley_ext - - -@diagonalley_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("diagonalley/index.html", user=g.user) diff --git a/lnbits/extensions/diagonalley/views_api.py b/lnbits/extensions/diagonalley/views_api.py deleted file mode 100644 index 71a2eca6..00000000 --- a/lnbits/extensions/diagonalley/views_api.py +++ /dev/null @@ -1,360 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus - -from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from lnbits.extensions.diagonalley import diagonalley_ext -from .crud import ( - create_diagonalleys_product, - get_diagonalleys_product, - get_diagonalleys_products, - delete_diagonalleys_product, - create_diagonalleys_indexer, - update_diagonalleys_indexer, - get_diagonalleys_indexer, - get_diagonalleys_indexers, - delete_diagonalleys_indexer, - create_diagonalleys_order, - get_diagonalleys_order, - get_diagonalleys_orders, - update_diagonalleys_product, -) -from lnbits.core.services import create_invoice -from base64 import urlsafe_b64encode -from uuid import uuid4 -from lnbits.db import open_ext_db - -### Products - - -@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_diagonalley_products(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = get_user(g.wallet.user).wallet_ids - - return ( - jsonify( - [product._asdict() for product in get_diagonalleys_products(wallet_ids)] - ), - HTTPStatus.OK, - ) - - -@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"]) -@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["PUT"]) -@api_check_wallet_key(key_type="invoice") -@api_validate_post_request( - schema={ - "product": {"type": "string", "empty": False, "required": True}, - "categories": {"type": "string", "empty": False, "required": True}, - "description": {"type": "string", "empty": False, "required": True}, - "image": {"type": "string", "empty": False, "required": True}, - "price": {"type": "integer", "min": 0, "required": True}, - "quantity": {"type": "integer", "min": 0, "required": True}, - } -) -async def api_diagonalley_product_create(product_id=None): - - if product_id: - product = get_diagonalleys_indexer(product_id) - - if not product: - return ( - jsonify({"message": "Withdraw product does not exist."}), - HTTPStatus.NOT_FOUND, - ) - - if product.wallet != g.wallet.id: - return ( - jsonify({"message": "Not your withdraw product."}), - HTTPStatus.FORBIDDEN, - ) - - product = update_diagonalleys_product(product_id, **g.data) - else: - product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data) - - return ( - jsonify(product._asdict()), - HTTPStatus.OK if product_id else HTTPStatus.CREATED, - ) - - -@diagonalley_ext.route("/api/v1/diagonalley/products/", methods=["DELETE"]) -@api_check_wallet_key(key_type="invoice") -async def api_diagonalley_products_delete(product_id): - product = get_diagonalleys_product(product_id) - - if not product: - return jsonify({"message": "Product does not exist."}), HTTPStatus.NOT_FOUND - - if product.wallet != g.wallet.id: - return jsonify({"message": "Not your Diagon Alley."}), HTTPStatus.FORBIDDEN - - delete_diagonalleys_product(product_id) - - return "", HTTPStatus.NO_CONTENT - - -###Indexers - - -@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_diagonalley_indexers(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = get_user(g.wallet.user).wallet_ids - - return ( - jsonify( - [indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)] - ), - HTTPStatus.OK, - ) - - -@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"]) -@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["PUT"]) -@api_check_wallet_key(key_type="invoice") -@api_validate_post_request( - schema={ - "shopname": {"type": "string", "empty": False, "required": True}, - "indexeraddress": {"type": "string", "empty": False, "required": True}, - "shippingzone1": {"type": "string", "empty": False, "required": True}, - "shippingzone2": {"type": "string", "empty": False, "required": True}, - "email": {"type": "string", "empty": False, "required": True}, - "zone1cost": {"type": "integer", "min": 0, "required": True}, - "zone2cost": {"type": "integer", "min": 0, "required": True}, - } -) -async def api_diagonalley_indexer_create(indexer_id=None): - - if indexer_id: - indexer = get_diagonalleys_indexer(indexer_id) - - if not indexer: - return ( - jsonify({"message": "Withdraw indexer does not exist."}), - HTTPStatus.NOT_FOUND, - ) - - if indexer.wallet != g.wallet.id: - return ( - jsonify({"message": "Not your withdraw indexer."}), - HTTPStatus.FORBIDDEN, - ) - - indexer = update_diagonalleys_indexer(indexer_id, **g.data) - else: - indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data) - - return ( - jsonify(indexer._asdict()), - HTTPStatus.OK if indexer_id else HTTPStatus.CREATED, - ) - - -@diagonalley_ext.route("/api/v1/diagonalley/indexers/", methods=["DELETE"]) -@api_check_wallet_key(key_type="invoice") -async def api_diagonalley_indexer_delete(indexer_id): - indexer = get_diagonalleys_indexer(indexer_id) - - if not indexer: - return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND - - if indexer.wallet != g.wallet.id: - return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN - - delete_diagonalleys_indexer(indexer_id) - - return "", HTTPStatus.NO_CONTENT - - -###Orders - - -@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_diagonalley_orders(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = get_user(g.wallet.user).wallet_ids - - return ( - jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]), - HTTPStatus.OK, - ) - - -@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"]) -@api_check_wallet_key(key_type="invoice") -@api_validate_post_request( - schema={ - "id": {"type": "string", "empty": False, "required": True}, - "address": {"type": "string", "empty": False, "required": True}, - "email": {"type": "string", "empty": False, "required": True}, - "quantity": {"type": "integer", "empty": False, "required": True}, - "shippingzone": {"type": "integer", "empty": False, "required": True}, - } -) -async def api_diagonalley_order_create(): - order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data) - return jsonify(order._asdict()), HTTPStatus.CREATED - - -@diagonalley_ext.route("/api/v1/diagonalley/orders/", methods=["DELETE"]) -@api_check_wallet_key(key_type="invoice") -async def api_diagonalley_order_delete(order_id): - order = get_diagonalleys_order(order_id) - - if not order: - return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND - - if order.wallet != g.wallet.id: - return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN - - delete_diagonalleys_indexer(order_id) - - return "", HTTPStatus.NO_CONTENT - - -@diagonalley_ext.route("/api/v1/diagonalley/orders/paid/", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_diagonalleys_order_paid(order_id): - with open_ext_db("diagonalley") as db: - db.execute( - "UPDATE diagonalley.orders SET paid = ? WHERE id = ?", - ( - True, - order_id, - ), - ) - return "", HTTPStatus.OK - - -@diagonalley_ext.route("/api/v1/diagonalley/orders/shipped/", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_diagonalleys_order_shipped(order_id): - with open_ext_db("diagonalley") as db: - db.execute( - "UPDATE diagonalley.orders SET shipped = ? WHERE id = ?", - ( - True, - order_id, - ), - ) - order = db.fetchone( - "SELECT * FROM diagonalley.orders WHERE id = ?", (order_id,) - ) - - return ( - jsonify( - [order._asdict() for order in get_diagonalleys_orders(order["wallet"])] - ), - HTTPStatus.OK, - ) - - -###List products based on indexer id - - -@diagonalley_ext.route( - "/api/v1/diagonalley/stall/products/", methods=["GET"] -) -async def api_diagonalleys_stall_products(indexer_id): - with open_ext_db("diagonalley") as db: - rows = db.fetchone( - "SELECT * FROM diagonalley.indexers WHERE id = ?", (indexer_id,) - ) - print(rows[1]) - if not rows: - return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND - - products = db.fetchone( - "SELECT * FROM diagonalley.products WHERE wallet = ?", (rows[1],) - ) - if not products: - return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND - - return ( - jsonify( - [products._asdict() for products in get_diagonalleys_products(rows[1])] - ), - HTTPStatus.OK, - ) - - -###Check a product has been shipped - - -@diagonalley_ext.route( - "/api/v1/diagonalley/stall/checkshipped/", methods=["GET"] -) -async def api_diagonalleys_stall_checkshipped(checking_id): - with open_ext_db("diagonalley") as db: - rows = db.fetchone( - "SELECT * FROM diagonalley.orders WHERE invoiceid = ?", (checking_id,) - ) - - return jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK - - -###Place order - - -@diagonalley_ext.route("/api/v1/diagonalley/stall/order/", methods=["POST"]) -@api_validate_post_request( - schema={ - "id": {"type": "string", "empty": False, "required": True}, - "email": {"type": "string", "empty": False, "required": True}, - "address": {"type": "string", "empty": False, "required": True}, - "quantity": {"type": "integer", "empty": False, "required": True}, - "shippingzone": {"type": "integer", "empty": False, "required": True}, - } -) -async def api_diagonalley_stall_order(indexer_id): - product = get_diagonalleys_product(g.data["id"]) - shipping = get_diagonalleys_indexer(indexer_id) - - if g.data["shippingzone"] == 1: - shippingcost = shipping.zone1cost - else: - shippingcost = shipping.zone2cost - - checking_id, payment_request = create_invoice( - wallet_id=product.wallet, - amount=shippingcost + (g.data["quantity"] * product.price), - memo=g.data["id"], - ) - selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8") - with open_ext_db("diagonalley") as db: - db.execute( - """ - INSERT INTO diagonalley.orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - selling_id, - g.data["id"], - product.wallet, - product.product, - g.data["quantity"], - g.data["shippingzone"], - g.data["address"], - g.data["email"], - checking_id, - False, - False, - ), - ) - return ( - jsonify({"checking_id": checking_id, "payment_request": payment_request}), - HTTPStatus.OK, - ) diff --git a/lnbits/extensions/events/README.md b/lnbits/extensions/events/README.md deleted file mode 100644 index 11b62fec..00000000 --- a/lnbits/extensions/events/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Events - -## Sell tickets for events and use the built-in scanner for registering attendants - -Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance. - -Events includes a shareable ticket scanner, which can be used to register attendees. - -## Usage - -1. Create an event\ - ![create event](https://i.imgur.com/dadK1dp.jpg) -2. Fill out the event information: - - - event name - - wallet (normally there's only one) - - event information - - closing date for event registration - - begin and end date of the event - - ![event info](https://imgur.com/KAv68Yr.jpg) - -3. Share the event registration link\ - ![event ticket](https://imgur.com/AQWUOBY.jpg) - - - ticket example\ - ![ticket example](https://i.imgur.com/trAVSLd.jpg) - - - QR code ticket, presented after invoice paid, to present at registration\ - ![event ticket](https://i.imgur.com/M0ROM82.jpg) - -4. Use the built-in ticket scanner to validate registered, and paid, attendees\ - ![ticket scanner](https://i.imgur.com/zrm9202.jpg) diff --git a/lnbits/extensions/events/__init__.py b/lnbits/extensions/events/__init__.py deleted file mode 100644 index b8f4deb5..00000000 --- a/lnbits/extensions/events/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_events") - - -events_ext: Blueprint = Blueprint( - "events", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/events/config.json b/lnbits/extensions/events/config.json deleted file mode 100644 index 6bc144ab..00000000 --- a/lnbits/extensions/events/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Events", - "short_description": "Sell and register event tickets", - "icon": "local_activity", - "contributors": ["benarc"] -} diff --git a/lnbits/extensions/events/crud.py b/lnbits/extensions/events/crud.py deleted file mode 100644 index dece8e6d..00000000 --- a/lnbits/extensions/events/crud.py +++ /dev/null @@ -1,168 +0,0 @@ -from typing import List, Optional, Union - -from lnbits.helpers import urlsafe_short_hash - -from . import db -from .models import Tickets, Events - - -# TICKETS - - -async def create_ticket( - payment_hash: str, wallet: str, event: str, name: str, email: str -) -> Tickets: - await db.execute( - """ - INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - (payment_hash, wallet, event, name, email, False, False), - ) - - ticket = await get_ticket(payment_hash) - assert ticket, "Newly created ticket couldn't be retrieved" - return ticket - - -async def set_ticket_paid(payment_hash: str) -> Tickets: - row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,)) - if row[6] != True: - await db.execute( - """ - UPDATE events.ticket - SET paid = true - WHERE id = ? - """, - (payment_hash,), - ) - - eventdata = await get_event(row[2]) - assert eventdata, "Couldn't get event from ticket being paid" - - sold = eventdata.sold + 1 - amount_tickets = eventdata.amount_tickets - 1 - await db.execute( - """ - UPDATE events.events - SET sold = ?, amount_tickets = ? - WHERE id = ? - """, - (sold, amount_tickets, row[2]), - ) - - ticket = await get_ticket(payment_hash) - assert ticket, "Newly updated ticket couldn't be retrieved" - return ticket - - -async def get_ticket(payment_hash: str) -> Optional[Tickets]: - row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,)) - return Tickets(**row) if row else None - - -async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM events.ticket WHERE wallet IN ({q})", (*wallet_ids,) - ) - return [Tickets(**row) for row in rows] - - -async def delete_ticket(payment_hash: str) -> None: - await db.execute("DELETE FROM events.ticket WHERE id = ?", (payment_hash,)) - - -# EVENTS - - -async def create_event( - *, - wallet: str, - name: str, - info: str, - closing_date: str, - event_start_date: str, - event_end_date: str, - amount_tickets: int, - price_per_ticket: int, -) -> Events: - event_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO events.events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - event_id, - wallet, - name, - info, - closing_date, - event_start_date, - event_end_date, - amount_tickets, - price_per_ticket, - 0, - ), - ) - - event = await get_event(event_id) - assert event, "Newly created event couldn't be retrieved" - return event - - -async def update_event(event_id: str, **kwargs) -> Events: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE events.events SET {q} WHERE id = ?", (*kwargs.values(), event_id) - ) - event = await get_event(event_id) - assert event, "Newly updated event couldn't be retrieved" - return event - - -async def get_event(event_id: str) -> Optional[Events]: - row = await db.fetchone("SELECT * FROM events.events WHERE id = ?", (event_id,)) - return Events(**row) if row else None - - -async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM events.events WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [Events(**row) for row in rows] - - -async def delete_event(event_id: str) -> None: - await db.execute("DELETE FROM events.events WHERE id = ?", (event_id,)) - - -# EVENTTICKETS - - -async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]: - rows = await db.fetchall( - "SELECT * FROM events.ticket WHERE wallet = ? AND event = ?", - (wallet_id, event_id), - ) - return [Tickets(**row) for row in rows] - - -async def reg_ticket(ticket_id: str) -> List[Tickets]: - await db.execute( - "UPDATE events.ticket SET registered = ? WHERE id = ?", (True, ticket_id) - ) - ticket = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (ticket_id,)) - rows = await db.fetchall( - "SELECT * FROM events.ticket WHERE event = ?", (ticket[1],) - ) - return [Tickets(**row) for row in rows] diff --git a/lnbits/extensions/events/migrations.py b/lnbits/extensions/events/migrations.py deleted file mode 100644 index d8f3d94e..00000000 --- a/lnbits/extensions/events/migrations.py +++ /dev/null @@ -1,91 +0,0 @@ -async def m001_initial(db): - - await db.execute( - """ - CREATE TABLE events.events ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - name TEXT NOT NULL, - info TEXT NOT NULL, - closing_date TEXT NOT NULL, - event_start_date TEXT NOT NULL, - event_end_date TEXT NOT NULL, - amount_tickets INTEGER NOT NULL, - price_per_ticket INTEGER NOT NULL, - sold INTEGER NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - await db.execute( - """ - CREATE TABLE events.tickets ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - event TEXT NOT NULL, - name TEXT NOT NULL, - email TEXT NOT NULL, - registered BOOLEAN NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - -async def m002_changed(db): - - await db.execute( - """ - CREATE TABLE events.ticket ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - event TEXT NOT NULL, - name TEXT NOT NULL, - email TEXT NOT NULL, - registered BOOLEAN NOT NULL, - paid BOOLEAN NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - for row in [list(row) for row in await db.fetchall("SELECT * FROM events.tickets")]: - usescsv = "" - - for i in range(row[5]): - if row[7]: - usescsv += "," + str(i + 1) - else: - usescsv += "," + str(1) - usescsv = usescsv[1:] - await db.execute( - """ - INSERT INTO events.ticket ( - id, - wallet, - event, - name, - email, - registered, - paid - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - row[0], - row[1], - row[2], - row[3], - row[4], - row[5], - True, - ), - ) - await db.execute("DROP TABLE events.tickets") diff --git a/lnbits/extensions/events/models.py b/lnbits/extensions/events/models.py deleted file mode 100644 index 0f79fa41..00000000 --- a/lnbits/extensions/events/models.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import NamedTuple - - -class Events(NamedTuple): - id: str - wallet: str - name: str - info: str - closing_date: str - event_start_date: str - event_end_date: str - amount_tickets: int - price_per_ticket: int - sold: int - time: int - - -class Tickets(NamedTuple): - id: str - wallet: str - event: str - name: str - email: str - registered: bool - paid: bool - time: int diff --git a/lnbits/extensions/events/templates/events/_api_docs.html b/lnbits/extensions/events/templates/events/_api_docs.html deleted file mode 100644 index a5c82174..00000000 --- a/lnbits/extensions/events/templates/events/_api_docs.html +++ /dev/null @@ -1,23 +0,0 @@ - - - -
- Events: Sell and register ticket waves for an event -
-

- Events alows you to make a wave of tickets for an event, each ticket is - in the form of a unqiue QRcode, which the user presents at registration. - Events comes with a shareable ticket scanner, which can be used to - register attendees.
- - Created by, Ben Arc - -

-
-
-
diff --git a/lnbits/extensions/events/templates/events/display.html b/lnbits/extensions/events/templates/events/display.html deleted file mode 100644 index 4c1f557f..00000000 --- a/lnbits/extensions/events/templates/events/display.html +++ /dev/null @@ -1,207 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -

{{ event_name }}

-
-
{{ event_info }}
-
- - - - -
- Submit - Cancel -
-
-
-
- - -
- Link to your ticket! -

-

You'll be redirected in a few moments...

-
-
-
- - - - - - -
- Copy invoice - Close -
-
-
-
- -{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/events/templates/events/error.html b/lnbits/extensions/events/templates/events/error.html deleted file mode 100644 index f231177b..00000000 --- a/lnbits/extensions/events/templates/events/error.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
-

{{ event_name }} error

-
- - -
{{ event_error }}
-
-
-
-
-
- - {% endblock %} {% block scripts %} - - - - {% endblock %} -
diff --git a/lnbits/extensions/events/templates/events/index.html b/lnbits/extensions/events/templates/events/index.html deleted file mode 100644 index 1ad3d885..00000000 --- a/lnbits/extensions/events/templates/events/index.html +++ /dev/null @@ -1,538 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New Event - - - - - -
-
-
Events
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
- - - -
-
-
Tickets
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
-
- - -
- {{SITE_TITLE}} Events extension -
-
- - - {% include "events/_api_docs.html" %} - -
-
- - - - -
-
- -
-
- - -
-
- - -
-
Ticket closing date
-
- -
-
- -
-
Event begins
-
- -
-
- -
-
Event ends
-
- -
-
- -
-
- -
-
- -
-
- -
- Update Event - Create Event - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/events/templates/events/register.html b/lnbits/extensions/events/templates/events/register.html deleted file mode 100644 index 4dff9afb..00000000 --- a/lnbits/extensions/events/templates/events/register.html +++ /dev/null @@ -1,173 +0,0 @@ -{% extends "public.html" %} {% block page %} - -
-
- - -
-

{{ event_name }} Registration

-
- -
- - Scan ticket -
-
-
- - - - - {% raw %} - - - {% endraw %} - - - -
- - - -
- -
-
- Cancel -
-
-
-
-{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/events/templates/events/ticket.html b/lnbits/extensions/events/templates/events/ticket.html deleted file mode 100644 index a53f834f..00000000 --- a/lnbits/extensions/events/templates/events/ticket.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
-

{{ ticket_name }} Ticket

-
-
- Bookmark, print or screenshot this page,
- and present it for registration! -
-
- - -
- - Print -
-
-
-
-
-{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/events/views.py b/lnbits/extensions/events/views.py deleted file mode 100644 index e1551320..00000000 --- a/lnbits/extensions/events/views.py +++ /dev/null @@ -1,76 +0,0 @@ -from quart import g, abort, render_template -from datetime import date, datetime -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import events_ext -from .crud import get_ticket, get_event - - -@events_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("events/index.html", user=g.user) - - -@events_ext.route("/") -async def display(event_id): - event = await get_event(event_id) - if not event: - abort(HTTPStatus.NOT_FOUND, "Event does not exist.") - - if event.amount_tickets < 1: - return await render_template( - "events/error.html", - event_name=event.name, - event_error="Sorry, tickets are sold out :(", - ) - datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date() - if date.today() > datetime_object: - return await render_template( - "events/error.html", - event_name=event.name, - event_error="Sorry, ticket closing date has passed :(", - ) - - return await render_template( - "events/display.html", - event_id=event_id, - event_name=event.name, - event_info=event.info, - event_price=event.price_per_ticket, - ) - - -@events_ext.route("/ticket/") -async def ticket(ticket_id): - ticket = await get_ticket(ticket_id) - if not ticket: - abort(HTTPStatus.NOT_FOUND, "Ticket does not exist.") - - event = await get_event(ticket.event) - if not event: - abort(HTTPStatus.NOT_FOUND, "Event does not exist.") - - return await render_template( - "events/ticket.html", - ticket_id=ticket_id, - ticket_name=event.name, - ticket_info=event.info, - ) - - -@events_ext.route("/register/") -async def register(event_id): - event = await get_event(event_id) - if not event: - abort(HTTPStatus.NOT_FOUND, "Event does not exist.") - - return await render_template( - "events/register.html", - event_id=event_id, - event_name=event.name, - wallet_id=event.wallet, - ) diff --git a/lnbits/extensions/events/views_api.py b/lnbits/extensions/events/views_api.py deleted file mode 100644 index e6aea102..00000000 --- a/lnbits/extensions/events/views_api.py +++ /dev/null @@ -1,207 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus - -from lnbits.core.crud import get_user, get_wallet -from lnbits.core.services import create_invoice, check_invoice_status -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import events_ext -from .crud import ( - create_ticket, - set_ticket_paid, - get_ticket, - get_tickets, - delete_ticket, - create_event, - update_event, - get_event, - get_events, - delete_event, - get_event_tickets, - reg_ticket, -) - - -# Events - - -@events_ext.route("/api/v1/events", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_events(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([event._asdict() for event in await get_events(wallet_ids)]), - HTTPStatus.OK, - ) - - -@events_ext.route("/api/v1/events", methods=["POST"]) -@events_ext.route("/api/v1/events/", methods=["PUT"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "wallet": {"type": "string", "empty": False, "required": True}, - "name": {"type": "string", "empty": False, "required": True}, - "info": {"type": "string", "min": 0, "required": True}, - "closing_date": {"type": "string", "empty": False, "required": True}, - "event_start_date": {"type": "string", "empty": False, "required": True}, - "event_end_date": {"type": "string", "empty": False, "required": True}, - "amount_tickets": {"type": "integer", "min": 0, "required": True}, - "price_per_ticket": {"type": "integer", "min": 0, "required": True}, - } -) -async def api_event_create(event_id=None): - if event_id: - event = await get_event(event_id) - if not event: - return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND - - if event.wallet != g.wallet.id: - return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN - - event = await update_event(event_id, **g.data) - else: - event = await create_event(**g.data) - - return jsonify(event._asdict()), HTTPStatus.CREATED - - -@events_ext.route("/api/v1/events/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_form_delete(event_id): - event = await get_event(event_id) - if not event: - return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND - - if event.wallet != g.wallet.id: - return jsonify({"message": "Not your event."}), HTTPStatus.FORBIDDEN - - await delete_event(event_id) - return "", HTTPStatus.NO_CONTENT - - -#########Tickets########## - - -@events_ext.route("/api/v1/tickets", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_tickets(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([ticket._asdict() for ticket in await get_tickets(wallet_ids)]), - HTTPStatus.OK, - ) - - -@events_ext.route("/api/v1/tickets//", methods=["POST"]) -@api_validate_post_request( - schema={ - "name": {"type": "string", "empty": False, "required": True}, - "email": {"type": "string", "empty": False, "required": True}, - } -) -async def api_ticket_make_ticket(event_id, sats): - event = await get_event(event_id) - if not event: - return jsonify({"message": "Event does not exist."}), HTTPStatus.NOT_FOUND - try: - payment_hash, payment_request = await create_invoice( - wallet_id=event.wallet, - amount=int(sats), - memo=f"{event_id}", - extra={"tag": "events"}, - ) - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - - ticket = await create_ticket( - payment_hash=payment_hash, wallet=event.wallet, event=event_id, **g.data - ) - - if not ticket: - return jsonify({"message": "Event could not be fetched."}), HTTPStatus.NOT_FOUND - - return ( - jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), - HTTPStatus.OK, - ) - - -@events_ext.route("/api/v1/tickets/", methods=["GET"]) -async def api_ticket_send_ticket(payment_hash): - ticket = await get_ticket(payment_hash) - - try: - status = await check_invoice_status(ticket.wallet, payment_hash) - is_paid = not status.pending - except Exception: - return jsonify({"message": "Not paid."}), HTTPStatus.NOT_FOUND - - if is_paid: - wallet = await get_wallet(ticket.wallet) - payment = await wallet.get_payment(payment_hash) - await payment.set_pending(False) - ticket = await set_ticket_paid(payment_hash=payment_hash) - - return jsonify({"paid": True, "ticket_id": ticket.id}), HTTPStatus.OK - - return jsonify({"paid": False}), HTTPStatus.OK - - -@events_ext.route("/api/v1/tickets/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_ticket_delete(ticket_id): - ticket = await get_ticket(ticket_id) - - if not ticket: - return jsonify({"message": "Ticket does not exist."}), HTTPStatus.NOT_FOUND - - if ticket.wallet != g.wallet.id: - return jsonify({"message": "Not your ticket."}), HTTPStatus.FORBIDDEN - - await delete_ticket(ticket_id) - return "", HTTPStatus.NO_CONTENT - - -# Event Tickets - - -@events_ext.route("/api/v1/eventtickets//", methods=["GET"]) -async def api_event_tickets(wallet_id, event_id): - return ( - jsonify( - [ - ticket._asdict() - for ticket in await get_event_tickets( - wallet_id=wallet_id, event_id=event_id - ) - ] - ), - HTTPStatus.OK, - ) - - -@events_ext.route("/api/v1/register/ticket/", methods=["GET"]) -async def api_event_register_ticket(ticket_id): - ticket = await get_ticket(ticket_id) - if not ticket: - return jsonify({"message": "Ticket does not exist."}), HTTPStatus.FORBIDDEN - - if not ticket.paid: - return jsonify({"message": "Ticket not paid for."}), HTTPStatus.FORBIDDEN - - if ticket.registered == True: - return jsonify({"message": "Ticket already registered"}), HTTPStatus.FORBIDDEN - - return ( - jsonify([ticket._asdict() for ticket in await reg_ticket(ticket_id)]), - HTTPStatus.OK, - ) diff --git a/lnbits/extensions/example/README.md b/lnbits/extensions/example/README.md deleted file mode 100644 index 27729459..00000000 --- a/lnbits/extensions/example/README.md +++ /dev/null @@ -1,11 +0,0 @@ -

Example Extension

-

*tagline*

-This is an example extension to help you organise and build you own. - -Try to include an image - - - -

If your extension has API endpoints, include useful ones here

- -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" diff --git a/lnbits/extensions/example/__init__.py b/lnbits/extensions/example/__init__.py deleted file mode 100644 index e16e0372..00000000 --- a/lnbits/extensions/example/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_example") - -example_ext: Blueprint = Blueprint( - "example", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/example/config.json b/lnbits/extensions/example/config.json deleted file mode 100644 index 55389373..00000000 --- a/lnbits/extensions/example/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Build your own!", - "short_description": "Join us, make an extension", - "icon": "info", - "contributors": ["github_username"] -} diff --git a/lnbits/extensions/example/migrations.py b/lnbits/extensions/example/migrations.py deleted file mode 100644 index 99d7c362..00000000 --- a/lnbits/extensions/example/migrations.py +++ /dev/null @@ -1,10 +0,0 @@ -# async def m001_initial(db): -# await db.execute( -# f""" -# CREATE TABLE example.example ( -# id TEXT PRIMARY KEY, -# wallet TEXT NOT NULL, -# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} -# ); -# """ -# ) diff --git a/lnbits/extensions/example/models.py b/lnbits/extensions/example/models.py deleted file mode 100644 index be523233..00000000 --- a/lnbits/extensions/example/models.py +++ /dev/null @@ -1,11 +0,0 @@ -# from sqlite3 import Row -# from typing import NamedTuple - - -# class Example(NamedTuple): -# id: str -# wallet: str -# -# @classmethod -# def from_row(cls, row: Row) -> "Example": -# return cls(**dict(row)) diff --git a/lnbits/extensions/example/templates/example/index.html b/lnbits/extensions/example/templates/example/index.html deleted file mode 100644 index d732ef37..00000000 --- a/lnbits/extensions/example/templates/example/index.html +++ /dev/null @@ -1,59 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} - - -
- Frameworks used by {{SITE_TITLE}} -
- - - {% raw %} - - - {{ tool.name }} - {{ tool.language }} - - {% endraw %} - - - -

- A magical "g" is always available, with info about the user, wallets and - extensions: -

- {% raw %}{{ g }}{% endraw %} -
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/example/views.py b/lnbits/extensions/example/views.py deleted file mode 100644 index 99e58f62..00000000 --- a/lnbits/extensions/example/views.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import g, render_template - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import example_ext - - -@example_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("example/index.html", user=g.user) diff --git a/lnbits/extensions/example/views_api.py b/lnbits/extensions/example/views_api.py deleted file mode 100644 index e59c1072..00000000 --- a/lnbits/extensions/example/views_api.py +++ /dev/null @@ -1,40 +0,0 @@ -# views_api.py is for you API endpoints that could be hit by another service - -# add your dependencies here - -# import json -# import httpx -# (use httpx just like requests, except instead of response.ok there's only the -# response.is_error that is its inverse) - -from quart import jsonify -from http import HTTPStatus - -from . import example_ext - - -# add your endpoints here - - -@example_ext.route("/api/v1/tools", methods=["GET"]) -async def api_example(): - """Try to add descriptions for others.""" - tools = [ - { - "name": "Quart", - "url": "https://pgjones.gitlab.io/quart/", - "language": "Python", - }, - { - "name": "Vue.js", - "url": "https://vuejs.org/", - "language": "JavaScript", - }, - { - "name": "Quasar Framework", - "url": "https://quasar.dev/", - "language": "JavaScript", - }, - ] - - return jsonify(tools), HTTPStatus.OK diff --git a/lnbits/extensions/hivemind/README.md b/lnbits/extensions/hivemind/README.md deleted file mode 100644 index 1e9667ec..00000000 --- a/lnbits/extensions/hivemind/README.md +++ /dev/null @@ -1,3 +0,0 @@ -

Hivemind

- -Placeholder for a future Bitcoin Hivemind extension. diff --git a/lnbits/extensions/hivemind/__init__.py b/lnbits/extensions/hivemind/__init__.py deleted file mode 100644 index cc2420d8..00000000 --- a/lnbits/extensions/hivemind/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_hivemind") - -hivemind_ext: Blueprint = Blueprint( - "hivemind", __name__, static_folder="static", template_folder="templates" -) - - -from .views import * # noqa diff --git a/lnbits/extensions/hivemind/config.json b/lnbits/extensions/hivemind/config.json deleted file mode 100644 index a5469b15..00000000 --- a/lnbits/extensions/hivemind/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Hivemind", - "short_description": "Make cheap talk expensive!", - "icon": "batch_prediction", - "contributors": ["fiatjaf"] -} diff --git a/lnbits/extensions/hivemind/migrations.py b/lnbits/extensions/hivemind/migrations.py deleted file mode 100644 index 775a9454..00000000 --- a/lnbits/extensions/hivemind/migrations.py +++ /dev/null @@ -1,10 +0,0 @@ -# async def m001_initial(db): -# await db.execute( -# f""" -# CREATE TABLE hivemind.hivemind ( -# id TEXT PRIMARY KEY, -# wallet TEXT NOT NULL, -# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} -# ); -# """ -# ) diff --git a/lnbits/extensions/hivemind/models.py b/lnbits/extensions/hivemind/models.py deleted file mode 100644 index be523233..00000000 --- a/lnbits/extensions/hivemind/models.py +++ /dev/null @@ -1,11 +0,0 @@ -# from sqlite3 import Row -# from typing import NamedTuple - - -# class Example(NamedTuple): -# id: str -# wallet: str -# -# @classmethod -# def from_row(cls, row: Row) -> "Example": -# return cls(**dict(row)) diff --git a/lnbits/extensions/hivemind/templates/hivemind/index.html b/lnbits/extensions/hivemind/templates/hivemind/index.html deleted file mode 100644 index 40a320f0..00000000 --- a/lnbits/extensions/hivemind/templates/hivemind/index.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} - - -
- This extension is just a placeholder for now. -
-

- Hivemind is a Bitcoin sidechain - project for a peer-to-peer oracle protocol that absorbs accurate data into - a blockchain so that Bitcoin users can speculate in prediction markets. -

-

- These markets have the potential to revolutionize the emergence of - diffusion of knowledge in society and fix all sorts of problems in the - world. -

-

- This extension will become fully operative when the - BIP300 soft-fork gets activated and - Bitcoin Hivemind is launched. -

-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/hivemind/views.py b/lnbits/extensions/hivemind/views.py deleted file mode 100644 index 21c4c287..00000000 --- a/lnbits/extensions/hivemind/views.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import g, render_template - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import hivemind_ext - - -@hivemind_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("hivemind/index.html", user=g.user) diff --git a/lnbits/extensions/jukebox/README.md b/lnbits/extensions/jukebox/README.md deleted file mode 100644 index c761db44..00000000 --- a/lnbits/extensions/jukebox/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Jukebox - -## An actual Jukebox where users pay sats to play their favourite music from your playlists - -**Note:** To use this extension you need a Premium Spotify subscription. - -## Usage - -1. Click on "ADD SPOTIFY JUKEBOX"\ - ![add jukebox](https://i.imgur.com/NdVoKXd.png) -2. Follow the steps required on the form\ - - - give your jukebox a name - - select a wallet to receive payment - - define the price a user must pay to select a song\ - ![pick wallet price](https://i.imgur.com/4bJ8mb9.png) - - follow the steps to get your Spotify App and get the client ID and secret key\ - ![spotify keys](https://i.imgur.com/w2EzFtB.png) - - paste the codes in the form\ - ![api keys](https://i.imgur.com/6b9xauo.png) - - copy the _Redirect URL_ presented on the form\ - ![redirect url](https://i.imgur.com/GMzl0lG.png) - - on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt - ![spotify app setting](https://i.imgur.com/vb0x4Tl.png) - - back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open - - choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...) - - and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\ - ![select playlists](https://i.imgur.com/g4dbtED.png) - -3. After Jukebox is created, click the icon to open the dialog with the shareable QR, open the Jukebox page, etc...\ - ![shareable jukebox](https://i.imgur.com/EAh9PI0.png) -4. The users will see the Jukebox page and choose a song from the selected playlist\ - ![select song](https://i.imgur.com/YYjeQAs.png) -5. After selecting a song they'd like to hear next a dialog will show presenting the music\ - ![play for sats](https://i.imgur.com/eEHl3o8.png) -6. After payment, the song will automatically start playing on the device selected or enter the queue if some other music is already playing diff --git a/lnbits/extensions/jukebox/__init__.py b/lnbits/extensions/jukebox/__init__.py deleted file mode 100644 index 076ae4d9..00000000 --- a/lnbits/extensions/jukebox/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from quart import Blueprint - -from lnbits.db import Database - -db = Database("ext_jukebox") - -jukebox_ext: Blueprint = Blueprint( - "jukebox", __name__, static_folder="static", template_folder="templates" -) - -from .views_api import * # noqa -from .views import * # noqa -from .tasks import register_listeners - -from lnbits.tasks import record_async - -jukebox_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/jukebox/config.json b/lnbits/extensions/jukebox/config.json deleted file mode 100644 index 91134bc2..00000000 --- a/lnbits/extensions/jukebox/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "SpotifyJukebox", - "short_description": "Spotify jukebox middleware", - "icon": "radio", - "contributors": ["benarc"] -} diff --git a/lnbits/extensions/jukebox/crud.py b/lnbits/extensions/jukebox/crud.py deleted file mode 100644 index 4e3ba2f1..00000000 --- a/lnbits/extensions/jukebox/crud.py +++ /dev/null @@ -1,122 +0,0 @@ -from typing import List, Optional - -from . import db -from .models import Jukebox, JukeboxPayment -from lnbits.helpers import urlsafe_short_hash - - -async def create_jukebox( - inkey: str, - user: str, - wallet: str, - title: str, - price: int, - sp_user: str, - sp_secret: str, - sp_access_token: Optional[str] = "", - sp_refresh_token: Optional[str] = "", - sp_device: Optional[str] = "", - sp_playlists: Optional[str] = "", -) -> Jukebox: - juke_id = urlsafe_short_hash() - result = await db.execute( - """ - INSERT INTO jukebox.jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - juke_id, - user, - title, - wallet, - sp_user, - sp_secret, - sp_access_token, - sp_refresh_token, - sp_device, - sp_playlists, - int(price), - 0, - ), - ) - jukebox = await get_jukebox(juke_id) - assert jukebox, "Newly created Jukebox couldn't be retrieved" - return jukebox - - -async def update_jukebox(juke_id: str, **kwargs) -> Optional[Jukebox]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (*kwargs.values(), juke_id) - ) - row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,)) - return Jukebox(**row) if row else None - - -async def get_jukebox(juke_id: str) -> Optional[Jukebox]: - row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,)) - return Jukebox(**row) if row else None - - -async def get_jukebox_by_user(user: str) -> Optional[Jukebox]: - row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE sp_user = ?", (user,)) - return Jukebox(**row) if row else None - - -async def get_jukeboxs(user: str) -> List[Jukebox]: - rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,)) - for row in rows: - if row.sp_playlists == "": - await delete_jukebox(row.id) - rows = await db.fetchall("SELECT * FROM jukebox.jukebox WHERE user = ?", (user,)) - return [Jukebox.from_row(row) for row in rows] - - -async def delete_jukebox(juke_id: str): - await db.execute( - """ - DELETE FROM jukebox.jukebox WHERE id = ? - """, - (juke_id), - ) - - -#####################################PAYMENTS - - -async def create_jukebox_payment( - song_id: str, payment_hash: str, juke_id: str -) -> JukeboxPayment: - result = await db.execute( - """ - INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid) - VALUES (?, ?, ?, ?) - """, - ( - payment_hash, - juke_id, - song_id, - False, - ), - ) - jukebox_payment = await get_jukebox_payment(payment_hash) - assert jukebox_payment, "Newly created Jukebox Payment couldn't be retrieved" - return jukebox_payment - - -async def update_jukebox_payment( - payment_hash: str, **kwargs -) -> Optional[JukeboxPayment]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE jukebox.jukebox_payment SET {q} WHERE payment_hash = ?", - (*kwargs.values(), payment_hash), - ) - return await get_jukebox_payment(payment_hash) - - -async def get_jukebox_payment(payment_hash: str) -> Optional[JukeboxPayment]: - row = await db.fetchone( - "SELECT * FROM jukebox.jukebox_payment WHERE payment_hash = ?", (payment_hash,) - ) - return JukeboxPayment(**row) if row else None diff --git a/lnbits/extensions/jukebox/migrations.py b/lnbits/extensions/jukebox/migrations.py deleted file mode 100644 index a0a3bd28..00000000 --- a/lnbits/extensions/jukebox/migrations.py +++ /dev/null @@ -1,39 +0,0 @@ -async def m001_initial(db): - """ - Initial jukebox table. - """ - await db.execute( - """ - CREATE TABLE jukebox.jukebox ( - id TEXT PRIMARY KEY, - "user" TEXT, - title TEXT, - wallet TEXT, - inkey TEXT, - sp_user TEXT NOT NULL, - sp_secret TEXT NOT NULL, - sp_access_token TEXT, - sp_refresh_token TEXT, - sp_device TEXT, - sp_playlists TEXT, - price INTEGER, - profit INTEGER - ); - """ - ) - - -async def m002_initial(db): - """ - Initial jukebox_payment table. - """ - await db.execute( - """ - CREATE TABLE jukebox.jukebox_payment ( - payment_hash TEXT PRIMARY KEY, - juke_id TEXT, - song_id TEXT, - paid BOOL - ); - """ - ) diff --git a/lnbits/extensions/jukebox/models.py b/lnbits/extensions/jukebox/models.py deleted file mode 100644 index 03c41d67..00000000 --- a/lnbits/extensions/jukebox/models.py +++ /dev/null @@ -1,33 +0,0 @@ -from typing import NamedTuple -from sqlite3 import Row - - -class Jukebox(NamedTuple): - id: str - user: str - title: str - wallet: str - inkey: str - sp_user: str - sp_secret: str - sp_access_token: str - sp_refresh_token: str - sp_device: str - sp_playlists: str - price: int - profit: int - - @classmethod - def from_row(cls, row: Row) -> "Jukebox": - return cls(**dict(row)) - - -class JukeboxPayment(NamedTuple): - payment_hash: str - juke_id: str - song_id: str - paid: bool - - @classmethod - def from_row(cls, row: Row) -> "JukeboxPayment": - return cls(**dict(row)) diff --git a/lnbits/extensions/jukebox/static/js/index.js b/lnbits/extensions/jukebox/static/js/index.js deleted file mode 100644 index fc382d71..00000000 --- a/lnbits/extensions/jukebox/static/js/index.js +++ /dev/null @@ -1,420 +0,0 @@ -/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ - -Vue.component(VueQrcode.name, VueQrcode) - -var mapJukebox = obj => { - obj._data = _.clone(obj) - obj.sp_id = obj.id - obj.device = obj.sp_device.split('-')[0] - playlists = obj.sp_playlists.split(',') - var i - playlistsar = [] - for (i = 0; i < playlists.length; i++) { - playlistsar.push(playlists[i].split('-')[0]) - } - obj.playlist = playlistsar.join() - return obj -} - -new Vue({ - el: '#vue', - mixins: [windowMixin], - data() { - return { - JukeboxTable: { - columns: [ - { - name: 'title', - align: 'left', - label: 'Title', - field: 'title' - }, - { - name: 'device', - align: 'left', - label: 'Device', - field: 'device' - }, - { - name: 'playlist', - align: 'left', - label: 'Playlist', - field: 'playlist' - }, - { - name: 'price', - align: 'left', - label: 'Price', - field: 'price' - } - ], - pagination: { - rowsPerPage: 10 - } - }, - isPwd: true, - tokenFetched: true, - devices: [], - filter: '', - jukebox: {}, - playlists: [], - JukeboxLinks: [], - step: 1, - locationcbPath: '', - locationcb: '', - jukeboxDialog: { - show: false, - data: {} - }, - spotifyDialog: false, - qrCodeDialog: { - show: false, - data: null - } - } - }, - computed: {}, - methods: { - openQrCodeDialog: function (linkId) { - var link = _.findWhere(this.JukeboxLinks, {id: linkId}) - - this.qrCodeDialog.data = _.clone(link) - console.log(this.qrCodeDialog.data) - this.qrCodeDialog.data.url = - window.location.protocol + '//' + window.location.host - this.qrCodeDialog.show = true - }, - getJukeboxes() { - self = this - LNbits.api - .request( - 'GET', - '/jukebox/api/v1/jukebox', - self.g.user.wallets[0].adminkey - ) - .then(function (response) { - self.JukeboxLinks = response.data.map(mapJukebox) - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - deleteJukebox(juke_id) { - self = this - LNbits.utils - .confirmDialog('Are you sure you want to delete this Jukebox?') - .onOk(function () { - LNbits.api - .request( - 'DELETE', - '/jukebox/api/v1/jukebox/' + juke_id, - self.g.user.wallets[0].adminkey - ) - .then(function (response) { - self.JukeboxLinks = _.reject(self.JukeboxLinks, function (obj) { - return obj.id === juke_id - }) - }) - - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }) - }, - updateJukebox: function (linkId) { - self = this - var link = _.findWhere(self.JukeboxLinks, {id: linkId}) - self.jukeboxDialog.data = _.clone(link._data) - console.log(this.jukeboxDialog.data.sp_access_token) - - self.refreshDevices() - self.refreshPlaylists() - - self.step = 4 - self.jukeboxDialog.data.sp_device = [] - self.jukeboxDialog.data.sp_playlists = [] - self.jukeboxDialog.data.sp_id = self.jukeboxDialog.data.id - self.jukeboxDialog.data.price = String(self.jukeboxDialog.data.price) - self.jukeboxDialog.show = true - }, - closeFormDialog() { - this.jukeboxDialog.data = {} - this.jukeboxDialog.show = false - this.step = 1 - }, - submitSpotifyKeys() { - self = this - self.jukeboxDialog.data.user = self.g.user.id - - LNbits.api - .request( - 'POST', - '/jukebox/api/v1/jukebox/', - self.g.user.wallets[0].adminkey, - self.jukeboxDialog.data - ) - .then(response => { - if (response.data) { - self.jukeboxDialog.data.sp_id = response.data.id - self.step = 3 - } - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - authAccess() { - self = this - self.requestAuthorization() - self.getSpotifyTokens() - self.$q.notify({ - spinner: true, - message: 'Processing', - timeout: 10000 - }) - }, - getSpotifyTokens() { - self = this - var counter = 0 - var timerId = setInterval(function () { - counter++ - if (!self.jukeboxDialog.data.sp_user) { - clearInterval(timerId) - } - LNbits.api - .request( - 'GET', - '/jukebox/api/v1/jukebox/' + self.jukeboxDialog.data.sp_id, - self.g.user.wallets[0].adminkey - ) - .then(response => { - if (response.data.sp_access_token) { - self.fetchAccessToken(response.data.sp_access_token) - if (self.jukeboxDialog.data.sp_access_token) { - self.refreshPlaylists() - self.refreshDevices() - console.log('this.devices') - console.log(self.devices) - console.log('this.devices') - setTimeout(function () { - if (self.devices.length < 1 || self.playlists.length < 1) { - self.$q.notify({ - spinner: true, - color: 'red', - message: - 'Error! Make sure Spotify is open on the device you wish to use, has playlists, and is playing something', - timeout: 10000 - }) - LNbits.api - .request( - 'DELETE', - '/jukebox/api/v1/jukebox/' + response.data.id, - self.g.user.wallets[0].adminkey - ) - .then(function (response) { - self.getJukeboxes() - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - clearInterval(timerId) - self.closeFormDialog() - } else { - self.step = 4 - clearInterval(timerId) - } - }, 2000) - } - } - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, 3000) - }, - requestAuthorization() { - self = this - var url = 'https://accounts.spotify.com/authorize' - url += '?client_id=' + self.jukeboxDialog.data.sp_user - url += '&response_type=code' - url += - '&redirect_uri=' + - encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id) - url += '&show_dialog=true' - url += - '&scope=user-read-private user-read-email user-modify-playback-state user-read-playback-position user-library-read streaming user-read-playback-state user-read-recently-played playlist-read-private' - - window.open(url) - }, - openNewDialog() { - this.jukeboxDialog.show = true - this.jukeboxDialog.data = {} - }, - createJukebox() { - self = this - self.jukeboxDialog.data.sp_playlists = self.jukeboxDialog.data.sp_playlists.join() - self.updateDB() - self.jukeboxDialog.show = false - self.getJukeboxes() - }, - updateDB() { - self = this - console.log(self.jukeboxDialog.data) - LNbits.api - .request( - 'PUT', - '/jukebox/api/v1/jukebox/' + this.jukeboxDialog.data.sp_id, - self.g.user.wallets[0].adminkey, - self.jukeboxDialog.data - ) - .then(function (response) { - console.log(response.data) - if ( - self.jukeboxDialog.data.sp_playlists && - self.jukeboxDialog.data.sp_devices - ) { - self.getJukeboxes() - // self.JukeboxLinks.push(mapJukebox(response.data)) - } - }) - }, - playlistApi(method, url, body) { - self = this - let xhr = new XMLHttpRequest() - xhr.open(method, url, true) - xhr.setRequestHeader('Content-Type', 'application/json') - xhr.setRequestHeader( - 'Authorization', - 'Bearer ' + this.jukeboxDialog.data.sp_access_token - ) - xhr.send(body) - xhr.onload = function () { - if (xhr.status == 401) { - self.refreshAccessToken() - self.playlistApi( - 'GET', - 'https://api.spotify.com/v1/me/playlists', - null - ) - } - let responseObj = JSON.parse(xhr.response) - self.jukeboxDialog.data.playlists = null - self.playlists = [] - self.jukeboxDialog.data.playlists = [] - var i - for (i = 0; i < responseObj.items.length; i++) { - self.playlists.push( - responseObj.items[i].name + '-' + responseObj.items[i].id - ) - } - console.log(self.playlists) - } - }, - refreshPlaylists() { - self = this - self.playlistApi('GET', 'https://api.spotify.com/v1/me/playlists', null) - }, - deviceApi(method, url, body) { - self = this - let xhr = new XMLHttpRequest() - xhr.open(method, url, true) - xhr.setRequestHeader('Content-Type', 'application/json') - xhr.setRequestHeader( - 'Authorization', - 'Bearer ' + this.jukeboxDialog.data.sp_access_token - ) - xhr.send(body) - xhr.onload = function () { - if (xhr.status == 401) { - self.refreshAccessToken() - self.deviceApi( - 'GET', - 'https://api.spotify.com/v1/me/player/devices', - null - ) - } - let responseObj = JSON.parse(xhr.response) - self.jukeboxDialog.data.devices = [] - - self.devices = [] - var i - for (i = 0; i < responseObj.devices.length; i++) { - self.devices.push( - responseObj.devices[i].name + '-' + responseObj.devices[i].id - ) - } - } - }, - refreshDevices() { - self = this - self.deviceApi( - 'GET', - 'https://api.spotify.com/v1/me/player/devices', - null - ) - }, - fetchAccessToken(code) { - self = this - let body = 'grant_type=authorization_code' - body += '&code=' + code - body += - '&redirect_uri=' + - encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id) - - self.callAuthorizationApi(body) - }, - refreshAccessToken() { - self = this - let body = 'grant_type=refresh_token' - body += '&refresh_token=' + self.jukeboxDialog.data.sp_refresh_token - body += '&client_id=' + self.jukeboxDialog.data.sp_user - self.callAuthorizationApi(body) - }, - callAuthorizationApi(body) { - self = this - console.log( - btoa( - self.jukeboxDialog.data.sp_user + - ':' + - self.jukeboxDialog.data.sp_secret - ) - ) - let xhr = new XMLHttpRequest() - xhr.open('POST', 'https://accounts.spotify.com/api/token', true) - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded') - xhr.setRequestHeader( - 'Authorization', - 'Basic ' + - btoa( - self.jukeboxDialog.data.sp_user + - ':' + - self.jukeboxDialog.data.sp_secret - ) - ) - xhr.send(body) - xhr.onload = function () { - let responseObj = JSON.parse(xhr.response) - if (responseObj.access_token) { - self.jukeboxDialog.data.sp_access_token = responseObj.access_token - self.jukeboxDialog.data.sp_refresh_token = responseObj.refresh_token - self.updateDB() - } - } - } - }, - created() { - console.log(this.g.user.wallets[0]) - var getJukeboxes = this.getJukeboxes - getJukeboxes() - this.selectedWallet = this.g.user.wallets[0] - this.locationcbPath = String( - [ - window.location.protocol, - '//', - window.location.host, - '/jukebox/api/v1/jukebox/spotify/cb/' - ].join('') - ) - this.locationcb = this.locationcbPath - } -}) diff --git a/lnbits/extensions/jukebox/static/js/jukebox.js b/lnbits/extensions/jukebox/static/js/jukebox.js deleted file mode 100644 index ddbb2764..00000000 --- a/lnbits/extensions/jukebox/static/js/jukebox.js +++ /dev/null @@ -1,14 +0,0 @@ -/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ - -Vue.component(VueQrcode.name, VueQrcode) - -new Vue({ - el: '#vue', - mixins: [windowMixin], - data() { - return {} - }, - computed: {}, - methods: {}, - created() {} -}) diff --git a/lnbits/extensions/jukebox/static/spotapi.gif b/lnbits/extensions/jukebox/static/spotapi.gif deleted file mode 100644 index 023efc9a9d2f387c4f20dbd055e4387902a3f7d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 219995 zcmZ?wbhEHbyuir9_+8$Rfq_AVfx&};A%}rs0t3Sq28J6941d6)Dk=;fDhxR)3=>os zwx}@NP+|B3R;uE`;NiiLkL2+d&#oxl*f zg(36?L+BrdPzIGy6_rpAmCziO&&y2T^( zhDYchk5Go3P?elekDSn)oX`n5p<8l7Z{&pj$q8kc5UMgE)MG+u&VTP- z&y7%qKcOmrLOuS3=KKkr@F#T3pU@kBLjU{;WnfsP!m!GNVO0*pstF9MwlJ)^!LaHN z!zu=qRVpg0JXBWYsH~cxvTBRUsv9b+{-~^C@K~kdvC6|^RgTB12_CDqc&xhNvFeY< zDu$d@Dmkk>a#rQ!teTLsYD>rFk#h}39D{QSoLSZ zDuykqRJN@0*s>~T%c==mR&Cj`>c*B;f3~b*xUov*#ww2+t8#9vns8&)mK&>X+*tMJ z#wvzCt5p81^7ykV=g+DMe^zbzv+Bm5Re%1hVqo~M!tmdN;eQUp{|OBLw=n#_!SMeN z!+!>q|0*i~JyibZsQjOx@_&oU{~Id*|ETKTN@&Av< ze}a{lM!{GX8Xe@o8)8#({~m{%_gx|HhX8f42N*xba`*#($3+|8s8qpK#;=@u`q-Vg)$iTp$_>+Z& zl|i0Chk=2C0hCP`IQ}yTdq`>|E|}89!mDJnVngDgb{<8&IWG(rxpoO@C+%6WG5J`( zjANe6OT#6ela#y{*{s}@dTP2(;<`C6jh6Y&GRl6mrP9EYX}*V$N{4{RihxD-!Yr~; zAulg3^PVV`I?F`s%JR^Sep6pfd39}d-0q^SvrKhvu1`IlCCVMP_U6{~o6B;&%h$zj zsd)OV_q6!B``a7ta;@9_{r!X8Rl?tSeIg2(IPH|xr}|WGdU|Sxe)hRDm7AZRTVUVK z=UcVq<)sz=tNqSaZGC-hL;UGJ-|B6-rzS3S^VlR^G(}}Xwfaw4nI8p@4|RSvTW4cg z^z3}CyM0_tRq3nKOO;(0S!;Y>bbGh|3(dNzIz8M;GHps+-_E5o!j?rYo0YJs^VzI| zZIaJt7hUswJ~!poO!ax`?_Q?QZ>T%Dd_iT>ibo5zl~!de>d||Zv3P==mS*QpMZwm7 z29*gbCwj`g%v`>pNK0$d;$Imnn+2C;JzOQa&1%7_b!z`T9tUb~8ygE}T{=exPS+Z0eQGevGXPH(w0%)(tu2YqmDc*Yn!jv?w2~yd_Wi zvh#Mn=+Zt~aNBc3^0L`kvlfd)Gi+=X5xk*sRPB4(j3Z)pnM$s~K4Df9dZLf~(BHR5 zQ%4}>%P#N74pN7HT;64==5TC(;D;YcvuC>m&YRi7=Jn}h-oMCCr!>y5EIM5^YtE)K zdY5I4Pa8ju*?i9Yu+56|THbx`yNX0*6s>3Z<7LRU z_e-7-R`_I|< zemrOr&zmP=&*(bkgykXa{AGrhRv+E#blv;oouCPVNB1)1Z**muTx?_ZlBYt=4{w0Wc}vDUMc^Yji=Q6_iVgi zF8}xQrEq`SuTKT@OTrverChcMwdj@z?aJPJ(jn`-Zl}ZI^1a_2{-2W-x$LpW-t5Ja z%X`R~ zT>kcdkE(Q*pkUPfEMFnp%`WU_A3FVdlth-Fn04pJlNLYC&=s3JR_P^u?2g_uvHV)- zp4U@9_M9ex zSLn1&Q#aHs)rqw94!`ti+O{*PI)yvEW3O$Re&EkigGx)Ec%jcTj=edlJpJa0G^5Wm z&-FY@UpHg%0*;fjoDVINn7rg{R?+9#*ICldj_y2LFspdZa~*Z7>zzmQ&YhSW6SCZ9 z?@Yg{O_%3=u2HxAc=Er0?X}DEzq4f6J-+GR#IY{ z)r>XQ%SQGD+wWcC-)f~I{AtQ07uQum{im+XwK84l+xjYay4BScL7}T6_O1$>?6R-y z#$wU64tL}%EoW_v>&Z!$l{Rs`d}OQF=e2pUX~(^a?apmEB)4rvyY7dsjY_$VB7!WT z|AlUC-}tn5!Is-D_d9l39{KqqUg^(7u}Mq;9dQ#TznJWk`Z983MjZ<0K|+KlKS=Edn7+R=5_Fe_4NPmOuA;?KgY3Y}na*x0@$y z_w}A<-doS;o+)KpdCNb|eJe-Kp=}vr2I`F8CaATa{(q2%Isf^D56}Ga{Lbe5i~T?C z_wGMclj1lTZd^$5esNvOYFqm}-9_TPCtElAYnV?9%nLr#CHy{b{k{x#&4`WR)jS)I zr%mlK(!3nAsH=h%=6)z%c_O9xj4058>HCI70oS;3A-XuD7tRtJ&i8235)pNCief{y3RIg zXa0|OncJ)HS??*|^ZM_55kLJ$b6nZ>ZrdT|;I~Cg*;aY+sc9cwyb>pwm1+iyx4AL{l0tT;6Jd)#yD2Y=W7d1n0oXKnw#PfOp|ymGGpbz^z;mO4??;1xM5B;Jqew)fSVg16j7F&)jWRbH+ zXi|x2Qmbgvn9-!Qqe-Wt>D{F!1BqrMi)NFEX0wWBQ;8;v9nCg3n(clxJ4m!hX*4)R zw76BYc+6<=;%J&B)abjTB|xGz$f7kwqsiq&gTsZG>QwF}4A!APS`#GNk}O*9w6~Uj z<(GF}&9P`4?1@;cDZ!-ATw%x% zAi!`TWu}lR^NkeG!+ioLOl?^W-rvx_e4;lfpqH7E;m?VNzH5D7jQW2`WNh@9@JnMt zUqshr#)+#x_sy=Dzk^yd`!%VlO83t3;?(avMoD=Rtq|A_`6XNUJMJSA^duxL)Y_H?2z zv-L|;hgnS4TAAHE5|fk4XL1`)+xw$`weqB;64PZgXS~qp<6aT`pTm^tq>j?d=`&tV zyd0t5RyljN=B(u>C*S3m*c90%k)-eNp-)J-?YzdM_b<9wGbXXjU?|JrFKAPY-VolZ zp})kSYsmzb{{`{4J9>SSxvzIv1pjwWT3cZ^@7m-S#*+^0=<=OmS!Fz(>1Vi8VNdje zj+v8suHEFC*f^gdVU~czWF|>T_AL{FZwfE^PU9U)wfO zD|6=G6$`jDICF0XXUXxNjPBGyDVbzVaddd@97QCENvh5Fjg&Ctvri+WyAl7 zE|$Ov_7~^;sW6!Lc+z3cHsi2{mEjHUnGK$nU5i&#eSb7{o4}Ia8T|WLX9jUid&@ER zzrv&kFS{8Wl8nWcyxG>-Rl4Y0MOoHSlLpuP0#B~j9$dKzT?bjGAMUI=@v^~u=OUZ* z*~c7be5jbgc&$NV@eE(F*{V_cJSTd$P1F2-b*aSv%QL?zO|YHW)%~M?u4QlbH|xr% z6%CR4DxNcJE4yc1vTT-G&{DNv2Zt$pVLZdOb|2;yHNTecJ*mT~X!ze~d75xrZo+)# z7g?+kZJZuG{{_MXCR@Ip(3Yb$Ay$KRzXR9N4$~VyxSvn1x>YIgm~(2#%egORTAs5o zd%c4*X_x-=n|*&S&F9$3c}`UzMIh|*4AX_TRvmb`wpXK5GIEmbsor^60=?nOrMV`v zXU2YsTFm)ky-{ahf5*gYr8C1HFPMB|{fCU1i^L{AoS}I%a`~@S4Q#i2|7nRl*4V(1 z;3$2wPt9sV$IA{2%L(5;O?>-ep@!tl#1(7!J-ghxShzJ9vv!0p{y)P!D52xVjmhOp ztLELzVM%p6O(WzLR;+Zx*AX0|ijp0oB<%i~i`{Z%_=?cQnIVZQOk zw$qhuol-4(;9d z{!8zBWWDW}^}c7-`)Y3Nc(Hrm8}9a}xA%RJ-tWn=?^E>tZ_+LMjgxK|KIKX z41e}B$sAy@IlvZkfTQLB*PH`9dk*m3IUw-ofRM~V5u1ZzF$X1T4ob~AD6{9F+?|68 zHp%C>b{72RpXhz4p!!hxibI(v4rRPJq!x3?z~-<~%wf%(!y0oAYxf+st~qSC=CJOW z!;XIr+wvUI*E!&|%i41+=gzUbKgSAWju+V+FNrx`R&%^!&hcEELsfT<*Zn!(AakP0 z=0r=(iME;(g@5!r{_i=_bLT|gpA!>gPEPVUp)7MWMCa(VGe>9sIU37zY~G!d3-+9x zuXAc)%+Vz^r32@eI&*6Mo>QCNoSM^f%6`p>$#YKc+H-o(ozwgNoX$CO z{D94wBQa-=)totT=h%)pXU^O?bMDWX3q2@0@3l zy})F9p(5tw%bIiV?wor%=K{yx3w*lg_}5+#u)Qc0dy&`oqIm8__PrP1%(Pe=nQg zy=>Ea(K+_w2bs$rbFX;aJ)yq$ir?QW0lvp|Wv_RWaO7@D&+N(})ug0Fe zD#d#(S@)V!|z_plfCNu_gazdb^Wu~N@}lH=w33Od#%RzTGrpI8E>!G z%HC+%d$o=CM!W2_&cFQbbFcUPy}=}VeS+=HDZFRPVsFlvd-Ld=tF65^=B>RkKlWPp z-|GuvZ!MC&wWRjevbnd`%x#;#_T~oJ+dFk`Zi>CVZSRTowYPWeycP*>i4Rn|t>b@Af%s?;QNU_EyTB zTc>;P+}FMLwD!(}+)3NCkuzLpJB0g0*FEr%z5i{l)Gdw^ z!g7+@N1`FrIr{dbX$IO{PwtNuZvH9p7=;dCJCd$5IYFGXHm`ZcPOpX=V? z3$RX}a_-?$yiN(5bd2p5@c(<*p!cCZ z@005Ohdw=zntA3O_#ewW3GUvvBX**pY{@j4 zd+(hTRT_`oH{CB{KZ)gEL!0YQM^Al`YSum%`=`eIAGG(rEcf}aGj8sr#P))J;q6Z! zCe<76w~!R&|9GrWvZdn5ts-;LePv-Kk|lLd&bCgQRQOnKzGP;%WOd1_i*LC9UEu!P z@S|(~kDmQM`tJXj@c+jo`JYqle|G%m4m^Ks|Gw7Hn?L9L|2a?o*8=-rbLCH*$~%6> zPUPIVhcD{F9j1NESn>8uQoEeJa%`R5%+GT=%cmV?jnLs+^K{-9+igq!o|8UvtJI^} zy!~G5Z@Vsr$9h5+J_>FAa?t9pXR$sn|LpJ2x}^RyJ^MW8=l=s-YZh>2D{y5iusz++ z_9Xw`i~N7D>;Jv{|L^Vke=qI-f0FT(Q?=(68F?B83 zTW~2!HE2tRk&b3e_oWj*O0TWKNjBRUjMDD?{dq!MJ3lg=KJ(N!RJ z$u;Ag#~o*Gop>^VC+1|-8M94U=a-tr7O%g0&DgTarNc44@Xf|JAJI#E@fA~F2i|mT z^jWjxA(NWKq2*a8bv4&)+2yr-%BvuWz<9iMeBo8FHbj?pX+^ovqo%P`a;fG&OL>m-8o`WKLT9& z>M|lzdFx&Txc={r%@9oGtCPE2#TTd)+&tYfWM=bp(~OBaqH$q?%BGp=D_g}(gDltP zoecSHsk=-f`|T6O`rwoEbj?n_+NvAnc+-4Q(54An7jRm|JXk3IU%kt4!PJn;--L40 zyQ5EUo|5$W-Rp_ke~+9l*I@Y@ZG1V{xjc0L?=3zH_Nt^PO}%RB+n#rCs?w2jrLR>O zE@=K06N$CIZ(Yj2yXD)itC44x+jr!OuAl45`!@8UYt*~+ncIGw9=fsgRp_;U2ECCL zC+Ag1Rd_o0ntZFU=dsLq=_H+>Usd`BRL?jnH*4FzWp9&mif^#~ zzQVrF@=Oxn$_}32JJh;2d8&2)324?@GJ`F0O<+Lg3s(lgLq;-@s%CdzKG2Uc+@U|Q zv7k&*=FGMNO{pWH;oN^Kpn2^pN)Z%5u^D%_w&7;GR(-&w9DWcay))yF|$p3|z!+H9T;)7&?uhHpD?DoxdB;sfpJ+xIMO+j!ES zFVx%FaO?86%+Br^Cv2V>J$651P@~Or_J2>j)Pb)Hi#MK*`8(mUV?~Ri>ck|)Z5s1* zdp602?@VcsXi8*td|^@bskg|?GuW;(V*NC9*cu6H?%F)FV)OA zpw=DVa#_usOVhJ)+2xZvh3#$5zH}6LdHU;(=f!SY6&yXT$}RrqVqeSX9lZbXY9B+< zvUQ1~wQq!1Eo-_EDwOc*Mb`3-D!YzPoXX_C%7tCdL$;;;Vx?bopI6wTp!4!uUxaN< z*b@H#>(ur2s)14!f_^_lHm-Z(lC)%!*OfXa6Eju8nE$f`mQD$r@p9pD$xfHv7S&$~ zs;P0eqMfXkpXX3h>9G2!EckVk*sGc_&Djcyiu~NVQ8T{uZaCi_6Y+tER1%o5Zt3RF9lt|HkYqkK__XGZ)AHx_G#S`D44QOp;LljU%dhAH}S8*i_bq zEYV|o)y;kF@|`0$jw!D->`PqpSdzW;xc$40J*|(r7hZAJ+GbERssI0)6lHPAlm2C! zCN1W9s_wn>RLDB_g?TZt@3u5s$Gy_s?^gQN$+>>tmedhW4Z^a83c{8lVqb$2kOBRXl&2Tfnb+JFKuTar8(<@vmN@`ol zvZ=4U8*(jA%)9q;b|%l0#*fb~FZ%XzrKfFH=yt0stNp&Njy;8p3| zecQg%Hm`8H^tA_W=G!03vPZSU8;`E}~MFF%`Cy5qd> zyZ?>MF}Giu?byz}??Z#~oQLe{6^E7Qy{`+u^GLjX%O1mhg?;5VJ4C)$oN%=(a`m-- z$Ta=ON!fXy+O_9A)o!1 zzR8epy?t}ux2=a`-<7SezWv$n`}Ws+-`C8qzVlu0$9`eE59Qx~KVWrF+Or=s>+Qa+xBvS#eg5xT?Ek+XeP8>j{QjSZ%k6A)=1Fg`@0LxCZ?|~l|NF=Fc$qKL>;HYs z|Nr;3{r^Az?f-Ay|C8H+N#?+BZ3h;c1OJQK4}v>*(B8iIbAeA$cDsm6$^cA%`?doHSDosiz#$JaR~DiIc_|C%r3& z^!6Om7jf2CIc%WgtYG4-Cd2wwd)@zSNsCG?cC#JXZzic;E?2I}2QN7qdgSQK?`O2p^r8IQCl#|t%lPPW*u6!G2l zucB_^Z4JtbNuoVziW5S9@%nk@tSjWbIxsA?cyPFf#K+WpE(C- z&Yt7<#CCDYsU0z_Cu7)eYoB62dt!#l*;*IhPcdE>QqHp9_1M+o7bxMk@Qcquk@IhD zywYskTkd#pyZRr_xp4i?i5()RuB%x0*4W=&;{N_f;MWlUWg@4Z`}lr360oi2Y%Pn& zOOZ2DXV2{@xxh2k{_qo@#cM8h)STXW$M40LbDvo*_N1ID%<$dCb7^ah*TE&N=coAA ziunJ3e#KMP)?GQ3_0W{l7E{k`v+wPmj@Og^21&{Cfp5TQs&T4N@am;m%JmdN( z`EciF?^le+ennp_z2Lsm$Kg+phiR&pr|1dU*g!v7|DSWtdV~g^Nn!nYC#ZAIm0F#v zj9-q|vYZcPJrz=VE`-;^>ukvLDfTg`XFEeeO;gVXl!jitbE&iB!eWuDcWr!mdV_9G z4yn2l@{{Fa5SV zRvJ0Yc6a~X8=a~*C){y$&D`(N=$yO9#k*(!?awzE-$pL%J+@FaV6kY#lE0CHEKwXR z!TCM=m+QJ0PKa7D_g2oDTdR2;R@&ZL@HVQ@;6nKwm*b8ctgct0U2bmq* zmfG7}OKiyq$<9~m>|L5s_hQ83CjJ=aTY?)Ik!t%MDHSGag*#q{r z2OQHLa4vhmwe11-u?IZY9`HVU!1wI|KifkAv4?_c4>{_@&VAh}5|%j0`GIKJL-BX! zlcl3?%zh}pj z=7%zTNeXe6TKgW!o_nNq?2+8PNBZ9$89dvl*7nHA?6JG^qyNTXk4^ndjM5&Pw=f27rHJTaKmM`kfGU~-%wE4-l$z?DFW&Cf6%jnvc z(XnlM9a~1vx)+__UbOgSw8t%)d?KSyEMvmEj4AtG^onKHhho_C>$DHjneUU+z;m%jbRX zUhaGM;@ESyx$}$M*Fvn?+dov<87E-;2D|B^W6MM+Q;hZ!YK}U+{-`qdKd1?dnwbNDf|4R z?Dvmy?4R!Y^(m--k~jaP2_OHYd;OE% z{!e=PTXp__G8F%8^t|Z5k$tgozNyLgBGdHG=H;I)o)_uFz5dXacy!-qQTumN^$&N6 zJ(GU^*}?pSt$yJ@u|hugFV6oHe}{b$U^fzAPyKLgj)b_S*LHL7?-oA#PbI}mCVu`L z&sH+&-Iu`dErsinJoa0Ln16AP|7x0FD$M`cGQ2pVzBG3AXHW6cXJViBm*rR~zOy|1 zS@CtIv@+wTL zVEvnHck{Dtc}1tbDI9)jrTG16TwmGyZ#;tEEB|vxb3gpwaYT% zU(T>AYy4iG{Qj*A*N>X_-+SMcHrfC9e_yU5b^c42^6u{Z;@tF-p7!Ft|L^ZQ zy!x%X_iOHN`WcyzU%5ViDV3Z%cm4bQ+kgL`bpFS^|CO^}|DL?P=+5!l zO(!&(EdM=@=ee}3c82-yd+&d)()hRie&zH1KXt@^p3*G1V$S(J?{EF_KdZ_s`p*5i zDfE8%{n8ute~-IWoXyW%!T;^SwO{Rd73%W;Pj9cE`Ru!-j>$o`j`;s<|2C^nP&nB9 zUxZKOM1i7H2Zy}YpVWXyhr0Qdi{8BWn0&lfP+Lvr=S8KHZ33FN9&1ceI@Tnrzp7-$ z2GtXjEX!wo$+)=mnc=A5X*Vs9!$b8l@)yU91_^|!p(9SI-bJ^f*pe|?9%YL3yZ&jmsM7jy}} zmO1$~^~A9W;_7iyGm9Rdnq^#Uw&!MX`qH+UVe>e5y+}E>+;y|r-CvttU7r!Y{GZOo zxw><K|J$AT@niA(_j$2hA~#-!Un$%8)@^pmhMQ#z za@e#^+-~MLckMxwU=YW{7M`q(hi#HYKNhwtT3JME3o)FKYuo5*$X3Pt?far)o1GlW zy;WB$7AO4ARb-W1s}ru!=g3ugX@cvmG?hs*vFr)fXZNPx>+#&Cp*l4*YUgGBw=v%* z7qXUFD9!jU+NF^;LvvE)vYDA)5zl62Y+L!PHUE^P`rPym|A+j9RV!8<^U7K+v(`d&xkcP8?SehOx-Kl=&t?5^!&%p;oW)bywrg+7GOb?G zb3W+!8uz$Ev$WP;zcy>_nj7<0>F&6j_j~P*heg$TI}^0Db$7p=_H^BngNLm3_u5^} zezEJmt^3*yXFgf$?En8u%V0z6!rxgtIpuA#ckwFk*|448+GbYXGd~%_!$R$4uX{W0 zY2OWJsoyjGgmSsc`)-YyI#)MJyI;C(bb8HNo0xqqYUah!$6EJnIw5yC`jb5)BZJ~k z7FJdU1_qt;jLeLT42%p694risEG#sUSi$&$ftP`gfq^5IfsupdL-vA$%^ZTaHc!2< zKsSh&{nl-#SJ&;kIdVN0{y2Yce?9x{+?yV!t{rcdTAGvd_2ZHQWm-#fjBXY!SnHdz zgyBO(@B^pnlv|NDnf{N?_31kDg>Wfkx=iq5Wls)q~Rr1nriATLxsnyrC&=r9*g>qL-eI2yM zs5|Np*S0k`H)mW{3OT)P?d|Oak5A>UxU%~0u8Q}s&Ymt`e}7+TzMP78#fFD#J6P4# zA}TgsI9#mkJdjNO!b$he?A)BbYl3XtKcCIn7cMTBU#!RbK>YExpw?r{w9CF7 zoxj%O=B1rh***)FvHs_?yOUASxMbUeN55`We4OI9^_rR9U-J*o+>*CV+O%}4?(1do ze*fJ$_q@+OP|rGf(xF<*UkB8(yZpK4X}!O3?dfw7?ULOr@6LR5yI;#R&m^Xjaar4g zrY_Mfan+1=S3~pV-i9Y+b$5Ojt-r!MMOt!pP5HvMe%>1kGK6GLOtEL!kRjadxba0& zmA;!rfc07pkHx*RWhPSXM|>^hCNS?*yP*GV$F%v07cHL)x3b?YzR+W^E_F`YaZ?H5 zX)K{TQ#;ojWJz_2m#myPlW$?<6UVH3-?%4k=T%ueNAzrFz*MH2I|b%R#kxwB1o!R~ zm=;s5e9otU4SiIf$>Wle`thdybPW{!X>DicPHK}XftdwU<7q*#Xu3WKf z)vHx&w!M0_dc!eMt?Z?n^H|c?3dgo*yY71Dl{RfN8&WdYVBF5o%is%1DGVFbH-OTn zFe0VcWURisz7w2M3YAytuC)TClqZM7g0@ANoZPh3yOYI!8PCs6%g)aJ7U1RK`C-}l z`6`~Co;y#9U0Cd4xb4;!*RG380v79SN$(b!IWK5)7VC%8_l~YjINT+>`|I@mP3b$; z{!~rKJ-$Wdu9!)8z&eNBYJcZ0hyT;GwYIS`6 z=HExwy?J~(ysqxw`Cs1-?rNXAfB*mg40YTnV&4>@+$p7YMDeK%id!vXU%GQHB)nq z!@RN=35|6}H4{1|OC%@9y!~~3>BP9KTZ<1GwI%vS$$hyyV@}!9SDQ00W!{=`#j0C< z3v=o*m90}QO1@p~@m6x;+EY=d=2SQPy;`}bk+UlzHs})kZ%x zy=^6Pt0Z=w3z}xI{f6Y9ta;Z=ttTCkbDE>%ClbHs!X|;Nn8nAv&vAY7_}!-?c>M6p zHJ@e+b>(b2$~kqm;qB$>N0mk$_B-#zh+;^)$fdsD8inQ$x61UZ#hndu0B@IJWo2LjZI@wWxWLHF z2r4KSt1u`uC`@SLrGp_scV?u)aWaYrP zHxfcBZqs$*CyDGVN;y5#AotXslf}!=&u}PZTkAPx*~K}o6XlMc-17XwQomL&-&-P! z8C1eI%N;otniRe!dUKQL>ZxyTtdBd{_IB4com<<|ug>y4U8WbaEAQ#Px!v3H@9nB) zel7cE`-l673mJvo&TRj9_js8yw^@$Fr<7yU#MO`KMCv(B=A57+AW&7pa*@ezw%b}O z)7RHmC-0ticbD0ln>%tZyTwMCytqHVIiJNY=7+=MBmLs>>-JQBdVXTDal2jYuHx5M z)`#C;x3~J+`x}Rg|Fhf2?J58Ksii8Dy$=1XxYX~$m$_tu$Fz`?Nsd~TOD6m1iKq4l zgx!2HH7IJP>a>s|&L`6n$|RpnPnzcWY^KY!rH^OjY}>gkerDm*d6C6+q01x6Pu&ck z$N280`rP_kk{a`xUva)z*repOVo{sgs~3y<&A47JnG|+Qeera+S(-~{NA0?q#&h+> zvlUCGSxu-~v23|k)tYI~vsSG=^#9kZRhy4_tzNU^-m2AWpDhi2wf?}SpNqnxITRjm zG}yM{%?2%_(~CA6s&T*FbYhXk+pSm4X20Eh+buhH`xP&4y=_vbEEQ^ws$ERp@Mc?e zJWI+XCjI^lyO&QVMa}AbzxP|;&h`8MzLGB38!R+u{lPzCf7T!1;?D`)n8~Fe5cw`F zr?93f&(KSIaSvnsoH?=IG~<+;V~Wzdmdb2OQ`w%g>9ofBHJ?r@9gg{Y+Tys)r=HBU zs*E+pyIKw$b(zj}WaWZ`?p#N{cg$qzd1Kq8vnojKI@GnyZixO@wyL(IE3qV z9PW{x_u)~W_P-xbCWW6fcrt^1`Lp|T9-l~0wYq(#=$v`G->(pKEsS@$0$Ye?DF8zwXED>t}0!yxl*4(XN%Y`SrCPS0a){zP{Ek z{Qvdw{QuwB9hmq&)U%5yFo?deXJgyYz!CF+nOEZ=i_?Whk(>vt<{1b1!ag)heM#VO zv~Uzk+t8xyqsSFraY)Q9pi!dcA@jqfM0 zvBTra!^}x1j;L9E>tkQVk%>|-XB@Zt^|8N!XNdy4=LsjNPZK(PQu?N?IN@gX zY2t*QB`WHgE_OkmCe1jqMAi7@Nk6$s6X)?P)!;sPEO6GRDRnj+THz;8h3)z@bwkfm zopjIBQMW!#`yHsNSAOzz+^{({E{p8s^rJx!2c%EzaYo06WGMfF&XSvn&ljq8IeV+TFM_u8f=J~2ypXdEp z!f)~U>iz$vnIg`4@Q zi+xpBrbqo);oe{wlqFr&dt7D#fUEg(U>hkoxt8K5Yy1wt%*Yyo+{bQKDZyb{P zwxMI`vjkynzkNpEHcseW6CkdA^HkKgO*78s$Z4*=nPIhU^8((r8JwqXUYzx9%L?DM zS$C&gzbf{5^Sa)(IqBACF8#msZQG8sYxCaUx^?%K(6)WN>k8Ued)yZKzT<@Nx}wCa zn0fz{?e_oBDCmF1&v@gp__v66I+2EaVHx7n76}|si!kKUE!wivUiPS!n_=JkeMPcN z3GEDS5ATJqn!fjI?Rt%{i-BK0ne2J6lkoe;_T}7p8(KC> z-Q!#@v`^*j+Ezo)2fWok4lA6y*zD}{NNDztBa^QfbP3;lD7yQ{(fhK7J?TD=rEcHo zG}^b(K6K4vx!*swSpM5Mq21@nWV`9J9k0E%pSI?#n0(nucfC*Y3(urzKK{5fq;FG_ zG56DWhnuG=&up4@kmtD0tbS5l)S8|!8*b+J+H%VPGkFMar{&e!vO zUGi{o2BSjM#rOVyScVihV=qh^c81@_4`U~b9m*W3ogoS%Wy4iwtW-! zCjS3k`BJZloq%_}_OyU6KFe+~RE#2R2$1*htrOJTWt=eK%3Q zL3~dL|9$=gpU=Vzb`B7_r6HouX0|p`r{O9S$Cb|N|)+IKb<)g zW2Y9ba`p4Sulwx%FU(tF(RZ!yM*NlL_kDAAx4u7ja&NQ5$G)E5Evr|5J~jXEr)lSH zo|&)zd9L{1=Xv$k8#wwS`ZoXhqUpc)Lg@L@YwPcP)s5f#Ci;H%#oa64&GfBMll`B# z{-0WJTh{)|sM)&WdH<8O5v9B0^0S`5&Ug`c|KHI&{~vw+{r`DDdmUp( z+3W2kub-E&STwkq)XZpY2xzb8&nWvkz4+qPTGomx*YHLmj>f0u%6GR{oZ4QJvApsL zdzD&5)57wmyhAm{VT~%!n?xi^E_`i@P;ZWQufMjm$l-daltl9n_WEzz)#P_1DX(aF zwY`!(qa`K1VfyqI&Kr&2Ki94-&9~1exhf6HE}+Ex@|R+2U?Rcc2{!-4&LabrtiMH7m^6}GR=f23#5btW!Xzx1F<}?w=f;-*5C^_|fyNBIUQl1gkGyEl1mPCiUbkny|lV zg1J-MwA8j4Et&FSeUnxCCqJLixn$y>#0iZ@C$_UpI$>HgcS-%v)QOW?CJKp_op>^l zGi_1^%fxA{jl!CfjvsB3D{@adW8vAJKG{Jvm8a6)(K5zlrt|e5MG9?`ElRp0j&*-u zJc)N&fuMNz5~1FI8T}R|6>lF+nh-ig^mAX4*_5}BCVy9)Jk7N1k$A(qOTAwYPW$FO z?d#R%w~Hnuoy@o5lAd%ZiJOJtk|#p4zhV^74SiUUR*+uDZH9 zVsqBrQ(ISGU!QQe%k@_gc)Vg&ENr}DuJ`tJcXwBOes%Zs_VxGoH!yR{`Rv&6@NkE) zcHEgA8y_E^02;5@x#{WY8OGV5@rvi?7dUsz`R>~C^74w{)p2KcZGC-xL-Of)zPq=* zy}hIO^}VyZx4*xCpqX3VZ_kd8k55e2jz70&=jZ1a7JJY4+q>)Q>l>T1&r35N`1bz6 z;qL$P{`>a){QTnb>iF~f_Wu5k(hNTj?uD{xJZNAM%XrYpp|;{d6OY-82h9R*8V_4U z!ZIGVN~EoL*d|l<;$gc&o5rIKm1!A|IyIK9c+{n{?Zu;RgJT+xdrYonfSciu`)t0w zc--&6ruk%oi&*B9i5_YzpG@*Gd--H?fScyiDIsB*Pp3wtt$aEyrtIa@=?QI`X)WJ+ zGN^m%BuwmvE+sFY$0pw=OR-T|O`6!_D-tB_0dZHMj9}O`S74 zN6~drSKQZb+ihE%-DdLd)x2OoQ7UxVT*1O$=U2>43q8MbX_RUo!=oOfb6P8-{;PIb zY&qsN)rx7&i4kg z?Y28*uitKe(5C%v$CGKaew))+!H``vn+x_8~_WM0wu4TX9`{UW__xt{Qd%b?j zth8V2-RAJ+1RUTHU$cQ>QQxi&hXp1}Z9F6(9`f-Bk1@|jectX>nk#wOV>U3zonZ3etc%L!*`LN{#UhG*IYUwC+WNC7W+Zj zNp}Mt=5Cmw@cRGS?T+gAV=tb_Y&J1kCMkaM+x_B`xldO3zR%t9L@xBM$>S;hN6g~; zq+f1}o1LDw?8u_*mLG9*g@5+np0QuF>SaRLtcv)F{5#8D7cKRB^1L#%*SdfIaZ#H$ zVf*Gyf9JIB-Sm&E`2A~~&g~EV{doCnRrB`_N%i8NSA7pHnso7S=#S6QTKA{Fe-izr z_LoTL`8t_7QGl@m37k0~2onh3J&S`*wUNoSpPQz088nvriCS2W|egE8E(0Lkzvd~zXwJpa}Omo|f$Egu1IhRh&i2v){B@*4zsTTfi zf?})4?2?~pVgFZX9+*>luk)&XA(uj<$l8vV&qK2&DIO4OlgqklpE&QO#-d6lEr-RE zy+U8w@BcGda}iU;ua_&9Ov`$OGRXN~>-Cx~*Ro!(-SKSI>vem+y?VX=0Gsxk4M)VX z-)uagw))McGiI;fY`);8{dUWhuz6WJl-{uYOO@7#?Vp~l{;==Wugx3w@;`pv zzx3a$(~GzHZR`$O?vi!p?Qyfpo{1-(u5&RuVNlrf>BR4KYo4CelU`;tq0(xN_!-0J zYl{7hFXt4;S-kI2Sys)eTXNoRm-d%SizN3NU-JClGySqt@LrRPc1d%@uh?9R*)kz) zzUa5^w4L`St3=xnHg)T)$i9 zBz(VS$CUctwomRUIG+oB+^w!@x&6OH+)l^o*-O7Yb1&=5eYQYf<=6b>-7~6QYEM3A z<+M8dYh~Zxedl(*efy~H_mz$15xd_Vcy4DpvGcL1bsy_*Q2}VKV_?vE!cfC-mZ1YO zU<}H23Jvv4Z~q#hPc0a2Nnbtn7;I|cVEo!`TcR@{xz29Ob+1!m{ohhRRrT{DWkNaY zzIbj~adEmy?yWBspsHFV&}*Vn;}qQ$5u3A~?#S#GiMHI@B^x~@+1luvz@*sIVd*!v z6+AWz(cZo;w&(w+dv{M?PrO&J^P5ZTPx$)>hfBGo-OlV-o3yJ}Ir;BbiA_&V`-vvs z`x9dP{Ji`BCJL)+c&qpBCmOi9XLL-t{wK?)Qf`r_B5EG4uKSTBYaefz3%`u;^uE(D}l^ z#IT2<1suK1py)-L4{+&tl5i9PTaIrdiP%4**o5cY}@L2 z=-a#KB@N>Jv-eE-zNGHyzkhYzx^I>}W&aT#n^+pXc7md-_K$_f;_JC9uXAaxmOU}w z`P+u{1YQD*r7i`hFn?$XCmA+w#&UL;SCu{O0{sQ;5NlL9E z<)7~#WEap+{ZoDZ<7Ms`{e^YbKR)uh+!tF?b1bHg<>sZnvhNSY50tWoru%C@?Ob+0 zSF{M8fEgHc1Q-h#1Q=)N}r4l+O#Za580|C>;_v!P4&;bZ%Mo!{~0sV^RqR+ymMq)?S<-$Gfv#uJ+poLobG)oQ<54=JQmJ#~k{b?A;liq1Y-CxPkG3 zYcbE2g-%H-DGFg%STp%0ExZ&PMWT9)mO99Xy$G0*IBoge@UL5)k4=p6a&(hAb8^L_ zIgE3}=g%oUH)FYD#j5X1L%#mKbWSX%tTVEXamytx%_TjD+B{PAL^LA~{Y~oE{dy&c zzi`eXOMykR{%(py&i*2dObl}v+Hj-+ai$@j2C6@-zt>RT9cLPF)$uXgMDY;636&oo zUl9B^TdO8v4nrnW%=vw?XUpE+U|;tCq+FcB&!#(v>;LD;)%^MrH8Fm_-UPcJKd-%i zemp+D{{R2qFOSc!+Z$0j&*$69sdLMp+!S|jW;`*mt@$UT#RA8|1&j)gB2gLv_8}`j zJ*dc9VH8)s`NJl6hGL#8kBS5@E)M8sEy{>%5A+p3AjNY^uuV#U&7j+%i025K8+3n@r(EopugHLNjvnVt!4ARg__1rzlbgq44 zmf}&dwyurGR-RbZm9^>G%vEnzrY4DIGb|CP*4AeJxoN^Srk_dv+i%uMGi-m{<-KnE z1HP2Jh;KJ8o!Y(a%I2H;|Nex@7L>f~c)u^}=jRA`x?^C_kzg!l@M91Jr@Qxz5+2=R z4VJ6eltN~FNI2ZeC+XGmqJYn}Lqa}F=EO$j2kZ_Ip4^T7sA81$*^+m9DzFDq?+<=t@)V&{(-v zJ5Z-7cu|byvyh#qmG%3J81{rpsfXU_6LUSrWbj%qW~GhqvpcIlth|4ygTH8sad-Z` zqnnOxGp)R#>1mpnzU{84IutU{kWX`Vuf5P=ht0V9|tld+L(QjHs3Pm+!Aqp zKZ{+7=h~PvXQzfvSviOW|0k!lcozo=ujiLC#; zH#3&5S^3ZBeyQc}729ihQ%^11^Y{1Tx4cvK$J_tg_~)-WY^24mRg8gK3EmE71O+4$ z!wj7DH^0(E37ku8hCm_y_ww~?mdwxB2yx8$@qyv!M0tO`IX@hq9cyCWuy3>*t0 zH~vT$u;=&~ctMV%)O|uD(?_ETEv&8LS9QcbI#5#jpI;lgQgMO|^U5CreO5wK=D0IQsU8#)ZQ8!5mBn(!gjTP-YmY(#WtPnE zVZCVaw2m#x^L}0E*GB;pcp5#!;fWs}XSfPoex{)uXWD;LKhAf*SN!*77>CTN{KhpA ztPz{{{FDl1n(5ts&&I0s*|o*|N8-|FpYs zf^F@OuTPKr_sj49{e$W6^!W6BE??tnnUzl5Z{%j0kxlJvlyJ}XL5S6prM!V%8UL! zyBYJQhVtGFp5wc7N2`ck(2HeD^W%0bTq*eNx=M4_ic61!kBe$VT#%ThRq`B*A< zCF2dF3G3P3O6ja;;f$WJ@vtgr#2_#AQ4;b>K?%kJ248T)@;`VLUw*>DMh;#z@S3`2 zep#uDc4`d8J8D%&QuEJ`l^0$d0017$JBr+QcE2Un~8dtt+}}= zgCpyepj2KP(r4@_*#{K&W=ZGlwF=CVtU8eT<*Ot-vPwrKS#u}p?-{_e56L{_Z2 zJWrjSy&x*#<(}+A0_+>QekL<0`YxWR9I*57UWeyf{CFC!_?@qITDCP#ui#w8p69H6 z%l}zyoAu)9$E;&o0z1AoCO%Wv$+JrP`XJ*-;C!h}k?HZJ$4yIaM7HyW`8(Y+G`SkE zJM_nu!0^Bd#nzBgiSSlgwGw%TAchUCYEu%oU+dD^mLcYrXlORAr|>0fSmLy!FCt?< z-<)(Inse%w1%1v=X%a~@MRz{#&er@H+cmKXQzrMV_u(W?s@N2CZjrUpG{_`8`v-$D)(Z$K({cd|J z8xvWU1&AKFf4P3!g!=pY=k5RhkKvzsLIVq5#sY_ZE>jOVYHkrf(DaFyCDBn>C}d%a zkd?*5HVM57#eBbBMmH(uF83sc#5op8HOg(%-D((DL@em}y_E5OH{+!arBKb5#+d$3 zt%mLl&Z`n96tZ5NbU|v-nuRJghToDdXfd~LimPJ1nlwN7erw{BK2taKfO5{WNf#Xx%+e5@#GEU*q>_zkPiF|9#(ozGGFG7qrIZiL4_#pUd4c{>tYIoU?4U9&=Dsn3B-KHPQXJvnW@H zx1&UnxNJ>8>x_gZj*CJK8hh6ql5^HmGjV6os(NuJTzFf$Y$f+uA%=eTrydu2*)KW= za4|1^5Lf@P_Qn-0o3@C%{m1ua2()F&u&6i&@m*03kJdY)(9HCA`GOhgT`STYS0$*n zsyR)Jyjt~J_vHP#->!EmKDaaQ_oelNoTC}@Q63rNO-mdmsrxcHa5|)THR^@8g`Ax9 zM5^hZf)J1KgyjzN*&E;aXl!}zXu#0*PsJ->`LX2zhD>v3ElLb+31^IPwJONIww|Lk ztyF8{iXcxp%ryEnkv|qSGHij|697p%#TIAR+O={sF0@7n zhEJ?vn&@<=^4pAx2~~_-49U{hI1{{kSvg-^5M%OK_}G!}lg8v)CW9H1I`|bmmN2M% zix6z^HWHaPfz$P6K$}2rMv7a6(3Pc8ZdNZA=^8BBA=~$9r^bY-Qc_eb{JuVZMKrWZdN1G?x|5lf9NcbGmSIg{Gr< zScvk}cN3>5)|88BFcj5TrN~eDYFR1JA!YfWBh$G{D=J{A_{~?Z+*Zn|ob1>!FC`;* zsZfdLvL!xQ{C!NSiKo@B$2FXUU+oNFx~+C({zN&Zi>VugTg`J^ zp3AkYs}Q-jOOfGqnMuxioxQuK)bF-qk?r|&ES-PipOwp(S3Ta!^w_afZ1=OqhkHBa zrwZi=t#gcysL7h{xbLJ~@{!!|e8(jL+wYvbd$wKI<*mh-DyJ{%JF|MT&fdjFqKr_9g)`CR30KKIK^ zzw_o_Z^Wpc@9Y2lWq$DgoB08Tf4o&3CG)_im|JaV6sTFiqU>>yGipQAnPP#LF6owhi_SGm z+r)CXr&|fF@@r9jqrl~#;Uuu^L$h{?B6sMGLlU<(v{@`!$QM|CP%i00yThM_0{@#m zjwniP?C_9Tq{egN2$NM{Lx4(x$ZU(F>RunaLXIpF-F)JxZq>%FgeyrByJs9RlKa?` z=A$fieZ?`eO&goCc0c5Ew>U0-NwCfGNV07DisN?oKK7UKsK_%~dN`i?FrnznVnt!j zUFN$!PVDPZQBl`C=@qo8BI!(u`0*dEzGX#|=RH}X;q2)ZFzeIg6*5mXSt~rfcWnZn zVxH{j{UozsVx@|TQgP>L+jp0y_qc-z!{y$&$?DPENPt+YIzdYaY$!H!&?Q$pKQx{s91ZO`oNq0G~8Cc@?Ws%s~3|Din zi+!6mEmSDY@Nn0YYrWMZ9yPD~kS#^0`mg#DyshWO= zPhDN#b#-;h(p6DwPhH!(YifmaZC23pTOlhhg{~{zyE^uB)b;JZwytgXyE-8->)NJe zqHCjNvy%k5uN@T&+t}OtCNfpy#(}79n`ZE4#(eL(ey-@-<|ek=gpIL=Z<!Ipa3LDsh{LPxlHH`GnV(sXcXU zDhNOHQPZPp+wN$d_Ui`~{(We_xx;{2k%Ms=(**%j5yppi`gk0bbIt#y*>=Sba?BnG6ie(Wms{WQ*=LvMHfJWK zOXsHhvNCX9v26NkxUnuy=EbwSGbWkXUAS|?=jEhL(^s+`y!lluE%DVy?c{PN`JNR| zGFv%1stvR~`cJn{_`cPLp=XoagkrOVNa5#6UVWcx%l|%6`+oj>fmcI!^7Sr`1j{l} zr4E)Q7c^hpc;tP}(#(;kWzPJ_NCrJ`hWfbcX+I=5^DpSV-+Eh7MPW;m<-JSOW?Vg; zw|dvL!?~NM-CcC~*=b#sdmIb5?`xa9IYhq2%8WV4mZeOm`GNK2M`0)0^mEc1k5!%% zUp8;fPo5+pZ-rx%`|i4bD9&T7VR@`xHmT@Js#c5ikt3}CCmQx})}~J5ZV)|Z7BrPN zmH&UkAG^PSee-x)QXl3hK4D_4+TE^q)mvZm;lsyYR)&eq%h9$~j9&56G3{%2i2DHr z9xjU|MpKo8avnJ`{^xfpdiy%}m-0jVm#qvnigT+ueCoa~^9*YIo0@og-C1sDfxZ`u z=WxHxdBezDe45=!Oz+mwcUTzxP$JzChTIrgAeY|D|Q`*WF9L`@MFRu#EaX z^W;pwmrD|UFD+ZP%*s^y_2%|h(QtlE5u8<~q)yMMEn{pbI88ysvzBzD(iphO*VCzlG z03tFR92OTb()G?dmF5sEPQ*e zJGh01czm~gqAovsfp+C&fy)=ut`-Y~x(h4}limNJ?H@UVfse5}%XSj;+i~{wDE~#sk%bC<4 zCmPj>H?NdPV@YgbYUtjf!H^Iju-8K1-Sc{;iER5N1dm1t?BeKo7ta5Wp~bhu^XXBU z(~}Bfj>}$%C_X3T`2JeK%r^7-@cbL%DfWvsY?8P|mKEQ8u732mz^usv42QT@U8}7( zw~$(v@_c%Fq5#vo>)k(B)cw^EC_Z5F(wvLQfuBi%ZO0C_Ee`BV3npYw=w_c;=V#3J zVMceN1NY(wbz1@^N^1)I{$3}}*>F;^u-GNvbdyo;1SO7(g(fAFGE)mBzGob2EbK2& z4GO4Z`Y(_?kHh@(w8`v^4AU}x4=$; zZS2g9V_ z%X2X~rlQQT%$8X>ja^-N@3MHGV=^D6_t#3W6+290He_Fi9~UaVT_7+0NQq-X^Th8N*Vz;vhJ|hu zn9anz1$czY@3$*!tT4WM zUEt-y^cu0YJG{Zqc&T>~?y}OL*qtZj=MpvS8nH<(; zEMLf!Fg+?k<>gP~pb0EN7b;C3#ix8wt2$}$c~)`u*PN|hY>5hNJyHw)r7vXG+Q4#q ziFnlp=Ijm3Qq$txWQvtkq7NtwYHt+e&KAwyD8{`}Wc5Z#>t<=|<_%#6E?at^Z4X`b zD$O)KTGM;dOy-sS#hd<@tjh05|NcY&^#5ndT*2I51Gv>T8LUm1Zp5_3-g<@;_m;N- zTU>6(yLE4IT)oBRx3Pjq+j(@7e6KNhEyJ(%ZY% zoL;(m_MS7V_neFNIAXo$iuK-$S-Wq%-hDHA_pRG|@0|9$`d@nAHSK-5DQOq0_q>SS ze7bwj+1vXryxw;{d&Ohx{ZG92pK)HffA;>{tM}hKz5o8|?61-XzG)v2O4*Zgbkn`V z4fC48rCR&G7eGYNj98#z` zq&?@5&YD98e-71&94uP0%f$46h0g&?AL}34e$3$$9C9W&$sBR9IpP*`#G~ej*PJ6h zdye?sITG;aNRZ6Y5Syc6F-Ie6jz-Nn8nfqU+?}Hde~u=dIr6n(<>b}zIWByQ6}T2J z;AA>*?AC{4xjM%S&m7C+IbKwAd~v|>;-2GWKF2G0PSn3SUc2XbqtA(|Gsips*PLjx zInf()qD$w*gfk~5_MDis=R{x3$^M>`ljoeA^5^6{nNxFZPR)rqHNEE4v^l3{_ncb2 z=G3e|rxy5}TE3>YNGJA+2W!rpnseri z&zaM2&YbNzbHV29rI@qlbIx9@IeTl)**iXGZ@)Qvx998wn{$t1&TZFWNIxFhzlD!Q zlaJ}axpy+NKm0kjL+1P!o%3Jkod4W&{&&s!e>vwFe9tr9Jti5dh_Oj*O%TBhJUCv&1jTLatz2en-#eMH( zpS@T7WUt!Kz2YBxHAwb~ukF>y-mA~Pcsy1Rh<|(T@dDl?-}A|_*OGj%F$rADUVAO4 z_F8uCwLIPH8F#Oh@?Ouky`EWny(;&5;oj@@Z?9LLz1|{wqr&%k+uiH!yf>QX-e|ph zV?ytZiDz$2ioH3d_GVM=&6&M7yZ7FlclYLkwKo?1y>ZKdf6JMDrk~C}lR3BQ?yWUz zuReKmYvbQrn|;sv>E2!)dwb{J+dFD+Z#jE=|JqwG{zPOPIrs0+Ri?!AEFZ7FwGmML zd*Q;_)9>coNtV39y!YDU4SdW8uWdhbjpaY*H4WX1ytQ{P>s~x1d*iI^U2a{0`?A;H z^j=`Oc;~^}yI*`JoQb{v#pmvWwfBGK-jnjZ_h0tGgV<|td(Wp>p7-uOe?pg!UGLsM z+k1?B4~70c__y|w?Ulo6=>noF@BXsodK55G-(7&?-u=se?_Pd8|0(bJf0=iF?&Xp{ zcmJ~v-x~vg)dr85945$}yZ30q<11$$Y4V+syLa#0-TUVM9-F^=ynPQ>&fdpYVjub1 zJ;{%G^!mXpb2|Y`ySrZR?rfBty~cnqr0%KNygPDvkB#h}G8sHhx%u?{*?Tf|PY?J! z)tGmGjRN1I15bovpT75X)%(<6~4ftmLyZU;;tDSaybNXIxU3b?w?{Qq+v-q>G=f%BL*!ObBzH5ncZ;s7- zIY&?6fZrRYjjyiu-Fb82rKR4hZGEpU#l0wunQ%<*O<3NW#d&Yj&pkNiJMGZAhxgar zJ-m;v_T9Z@?;c*X6S!vgPG%q94ZeGCbokg-zFcOYzO`xx5y zW|`b0i~n^`-n@IcHT)@K{S!9NX}a;B((69GR(PCz@}nZ(Csq5$S^wUut$*ot?&%}G z{>*>JPV~NH3j7?%->>ICZIRv+{qUFi(_e10yQ|Rm^-aL*7&|`o`nTzQA6(DB4*vHr zWB#3M=lEjno}0#fePb}eng6TB`Y-W)?~UW$XxD!{AoulO=basMf9&r)m%jDfS=rg~ zZ)ZI}^ELbJqr$r{FWTN{ogN-fARb6om;V=MJ@U6 zzn!oA|I;!(F5|jah4y^&>VIyPdwnKu!iRakT>U2m&-;Bi?`K%vtHS@sGWPw+h`(oe z|LIn_PaF2%IdJY_%F5p%`}wl|=e@qscjvCZz-m8!9=>Z4`Oo&}Pf$Gn>O;h<)4lJC z=e=ib{MXd~Uh&_##qm#7Z7-?q{m-E2Foz+KiS^26b%lpct-O+UB@%&`^{e-@1Z?T} zm~^yP(lU$XXW$ZsNvgh+bSfq#oSdQ^y3Hg~VA<)(y1A>CND4pqonoE+=*`bbPXiXZ z*7qq{8mI@(xAI(<^ywByF%B5Zi@8nJKHraJ96#g3cptE z(7cdcw$4>YZU^R{=n(k(jrGs;hdY+Av1UknSZqAl=*6ogX256o_{3yIZ?(#qn;swQ z*OmS^$D-uv(O!3NJ=vdzFV11C-2eQS<8-Y`bI$HI4`0V?Q?%nKt9vH* z>a!L_YfrH%w7!%1Rs8b&O8x$MbAOe*y)(1=_`bVUU+ykHJKbG>_Li!9w*=VQ^mi;2 znIz(^cBf*$-M_YrN4H=F)#+ zaG1~hi-C)P`;`qx_!B>=ANUu-GGV{i*U9?Femsa~U)ji&mAh1?a7oYcb2TO$C#36z zgO1B}x)dE(=?~fDsW$&l;Yp3TbA(PQEZsBdl+Ie2pwoIgO*VNMZkPFd+UT%~v9Iw} zm*NHAQ(B5$Cw|(Mz25TeA63@{)1(U4*}Z;bd|~E4m5>X|9x;{t4{%=T^(DYw+;vNU zt9t2{KsVL3BA2}tYo}cDwKZM2NaX#R%~ve^=N1}Fx?gF#Ab3?{MQM<6)a5n?!Pl@w^*J{pM_u54)V-eNUY19u{#o@a$CM7_r1B zX3r%4u6(XAJNwwrw7JF4D%0mzv;9h6*sNBSvAEmqSH{xGX;qoaXSe;zT)B8z)&H#3 ztB?IkuMg-gRtnm`OgP8JdX+~`bFZ#Vk#?tZ)VdC2daZTHKn^R_*9yPdQ5^}X3| zc7J$gU9jsVo6LuUZ{PjSKPsFqvp(>%%bfg~>d$8toz_17r|7Kl^P1xG*6e?aFFLE& zmRt_}`t4~^c=+B@@kqJtsYS8nKTEIWF0U=SQ@(z0`Q6&-wm)vPx9|ONyHWk$kNeZb z@BO$lJ6!J9^U3q?RoHzwYnT^Yj1zefvG0fziU@*h>9cc9R4q!5t2)W)m9uOBOucqiXv$mA~@Z z?u0Ko9kx700sot2mn5*Q{_ZH$ccDd;l0S6DwR)%?vyJQIA+*Y*qR=aC>6ZJO=4DHZ>~CIp||vpisVO)Qvtgs_ARkl zs`);`%W_lElvOOLTG5)P{}o)CHtWb!-O`!f+I&TmCWb6gX_fRzV!AwIzsoa&{*^vQ z-d>uyrzXv0p{4KHO2KI>{>P~5#_sgZEi;~dzem+#qoiL^-{m>iU7l}WvC}Ve+vK_L zbC&DRv^<}rwt3!{m}j15mStV}T&ap{bpt{&AK}O>z$@XoU$_8Fimt#++&d&hvuk+xN-veC8Cx93Yu zRoTF*6*6_U#>TDQw^EB^#w@^Pf*eAJd z3d=TAu~oKNHL1+@J6l7`=H@N*N!3|hd^#%c;?$LIp6b;L?pPabv}21;n)ph|oj)v# zd*AO)7kVYW$S6*JQg2Pv1Fl&oxWm>LJeEnB@pZ<@xvw``apopF+25+Z?7H&SHlN4a zHkDr1*=AfGUlk2zplz2lV9*RN${kX*a^>n zTYdR?<=v$tFC_SFYMEL6MpSZ};=#IC8v>7~U;P<*ZndB8ti!FZn1myGZyl4XFP(n> zVW;)s2k*Ysd(KRl#ycbCPMzQ^VV!3s*Q=WYCdx^ey9x_*0S&Q9_B`)9FsHCyHRpKcAVJl!4tx$u0!CX?{Gli}r`3!csS{^;}HuZ-({ zUrPP2zl!~td(XS7Pq*t{h?iymJpMoN&i?Pe?$p=+IX%7XW$^t2I_`427uFxtdS8Bc z?VRm@EcVHAZx6j>&v04cZ}Xblr|g-14*WGARu$K3bmtvEF2d4Vt?TZoIU)_r$@iofdDb zHhVQ&eBLd5Q+c*gvZ=}+sp!{^MZ0I)yx!94yp{j3+4hN(Z*tF8*s|#$r&GMQmG#Sm z6EAmYeQ``m+RLz8l}$` z(>t9VQq25bAHJ|?xA>j40xT*Ir0pJBZ#R^c+8>blij4_LBFOqC3u9JzHw;xEHm!*S|U5c*J30 zwC2al9e+0*HD%tuCD2f}=7h=aBik1p(X2lHUsL7Sr`4`~4~)W!4=Jad=qPbY^Vu5q z*+aY9t@X*l$TzF{=gbbBZ4vzQNZS*S%FkP-UNKIqG5?r-$otJv!zIV&upGUuy(H(3 z+qTDRiq;&@mGPRhVw1f0rhuD=3;!t3sc?6;KH~VN-}KM4Qj={-(jF^xj3i4AF23TG zmhB#Qn15r;Y3?4Iyc)aeh|@8P?Cp|wPJD7C;)v^%J1${sPK5Zlg|-|EOmQtbe01;T zV_Zht6;8VZ-f>;YVjgn(bk-J6``w#=pKzRH;uH18``@c0Y$&)I}bj zC0CA2vGMihIdk2_C27i8RvzC+lN>CcxSf%97klEnMaQLT%klrkB0bB#ICJ@&p5g88 zdSs*HZSC4SUama5gk|<_d9r4Gj+yiC<7RJsU4=c~$cT&B9C`OqFVOkKgexX3TTY(4 zb0TW?){x-Y`~G+)Zt)Z{KD2qI$MiQY?I!2fz4GYJ@o#6*m=WSB+Uzeg#mY#=@a!4? zlq=jh)@SrYPX0+bna*=`;u>qN(135+Uf);GvFVxl^i6=Z$i)wer$aIw?Q||$@A0!| zS$q4Bn`mpGTTY;)thdP0i!WTP^M9|*I${~L%W+$9AcxL|m^nMM&*)s9vZqk zRj=e(=4$_pTz$QGrE#kK;wgu1Ol=L`YS=ARvcIcru=cc4uifO!f$S;!o@!rNP!;5y z8st7#S-f?BYPIuEYuih+ujrYc2+Vf+x;U6&mA`+ey~W-gMzQ;?UH$jWy5zGo#5?tB zhR_vf-w=)1E0L`sQB$u*UA-C;d&E9EG(1&5LHBg{-|fDCmlqpf^|w{B4h;)d4ehqR zns!t;?W(km=(P`f!jkSTiO9W@w>30B)TiL-Rd>VQfV4-O%>bh?v$J zow*V1FD!3#mufW~y>W74xbc*0{adeRq%KNdsy*@Rb&KAO@mGU$doK#4Mrtp;G(9)+ zqGU+P+cQOLBPLlN=-qR5p6itY)m1%bBIj?7f`QoYg(BUtq!?6(j3!6Szt z6^A5OPmKrxfuI%Y6DDf6mBdWsGptX z+M?Jr^GFY$?@QIHe?PgBCU7Y3zOenakjc_b%$(7o))juY1T@ckPDofY#i@LS!|aq< zt8~}0RV-TKd1KMNg_Cw&U%YoiQ1wcNwXZ4?Yeg>4Fzo-~YyMG;Kg=WBrD@yjIHt6B z5pQ;_`{WU@g{v}CV8g$YGWyOJPu*O4jKy%K+B|JUXR!R%U5mj?1)))P4v0c>U+W~tuM^GBgInU^ox%(f1{i57Uc`ktDY~Nc-f1^ zg=xhc4$iXgpPIj4ZXNQU%jLzav$h#aYajd8 ztUUN@>5cg-*$&%&?femyS2!bh_nsZo*FN+8cwx`9Sz0q#+V}(ewlQ=4IOEv#qvGPd zOVb;EPmQkreJb)3$D_H{w{I9%89a`x@6h48_3M)A=I;l4(w{TWnwPP};&^4Zt6$d5 zQ(kQj%YD3cVNqD%%W>hdw72Q;s|H#QNhyy5wj4OF z^C6JCj^~N|<_&JPQbKC2HcOPmPo97undW)YE9%pv88#gSW|4FJiat%A$D^v@u6ZhG z(x)lQd{ni|OjpQi8QQM)$lvFY1{{9WN;>=o2M&18wX>`Kq@uAphFwk$QMUFj3- z6+Hc#$uje~D}5zbDP{zHUT(*$70|AAWs%U=6>h#-LDN@VS!VQgrC+aB2xx6i(AQOA zXSKq%ue!Pe{wVU)SdKYR5d+y1whu*L7uQwd208 zy1wtz*Y$P0ItlEv!x(u==KoV!;qZ9pje4`Njh(J<61lBz9`~BIapF>)H0{}ur?akY zoXe}5?!NoxrKYk?>_=bvg`SR@yUlF*hS;^)>C(5a|39^D+m^j+^Kwt$zVl6N`+>V_ zMY1l2u9Mon>JeIori~e~!>f0yJ_U=bU%Ws^?u-iQA@|kC*yLbLyeSPc3 zIfXvYEpP8USFvyNyw5&yU)g4!sP{9PyztHw2W!a-t$rp8#cf}>+26d_k+x;ZPM$Qc z_MMlKwryEvd^Xdk`q!leX`1b~eO{T*-gRY#-PTpXXS1@_Rh-}Otawej?(3+_zpiat zx4KW6H`CGn*Y!PY+txSxzDbn+ec`y-mQA8}vl;z&U0&GsZJE98+l=7oTQ~M?+qT&E zS!ng{s}KHd+i}?UU17BN^%v(#cU@omu59z~ySx0%Hn;Y^snVZ)@yovQ&0pufuN1$1 z@87g?j@7;&a-x4fV7*^{vZFo|L5MKDVy&+`5R_=CVJhIbnQJ4E%xs`C%Nx)`*)k?Hv2QS8~T0e z5&!$b&EIP0W0_CO`v1K2>$keH@Ll?J4J`*-jA z>XN!^p7B@tTg)5|C9-?U@;laV*D4h5%+J1etoX+x_WM6VyU5_W- zE9Lii{=0a=U+wk}SFZ3F`OmZY|Np;!{C}-ux!kQKA1*p3s4za5JoOTnm&1GSUrHhq z@4Zz%YVYcPW&Nf8dtd&GH|%RIPd`@Uy1DAaQ5TkrY_6teY0T9ZLIi$%suJO7blYB7 zUZzp7sgdzWy;M*#qlv8Wim}}y^$!=y8ynS6QESu) zHDeJ}+`hCvOTBiHdD}ntHij2bE5g%~Cuz%UWrZjN-!8m=l|ixCnR@^j_DfKYB#} z!47M?4xbbTwc8wh5?_3lB=kOfp%~dBFR!S^Y}ja7HlTX za>io9?+#gpPT70zQ8%ZTGQMmH;K-V5(VrbIcbP*W<%8O5b%7^_Y7AGZzif0|EW@bwq z+8_Ne+kLrC^o#tq0u8SuO;$mzSA~7fN=|hh0?aCAaV<*cE;io|a$^v-;VP6DFl?HV zG2yL4%$5UOnLDRt-JF*5b6TF{^a9K2MUm4>Jh`_7aDV+!vv5;K(&Op1J3AV0nl@|7 zwQ9=Obv7JtZJg23HCv5}fgwtPtubXz zL(81V1#`|+&N(-8&iS2lF5aATA#!e%0o&pL?($GGmP>ZWKB?SaseItSllo)Mc`KCX zJ*?C=Vr%{(q4nTI+vzLTih-KH3>!E86m%@cNZMJ)!6 ztVK$<7HRHUr2K1H6x$XDe(p}$rxNnKo)eg6`nu2Z zO({_Pw6T;wGc!nIB46pmV2waVC$?Z=-NO?dwHI|~E@|~JF=sy5Wg8qgvr%)l02V=UUOMwIae{g>KY}uBsJn|GQT7 z-CEJUign9^AfFJQw2r{#O_`Yy4XQ7LZ>^ZZwo1Uf!Z}D&_nYKYp;fZN%`E*zv;8%F zy}p>GIZaax5jc_}bFM=m?(x)9Ap#0ZTqi|zUJ2ojn!p$tu=vET#V2>IIdyBznO|$p z?OH6Uz*WUr`R+t{xJ&GGi}aZs44XZ}@;KBvox4_OC~-2#l?Shzm9f0QskN9{X$^<^ zYK`tc=~E6#gl+9yz9Vv-CTCCb*L6~sVcA{Fk9LGDez3e@)gtv(8+5r=@SR@4KYN3K z^~UaBD@46F^3Gntdz$N3WLSGr+!66jn=B&OisO#Y=x37>`p_wRgL6@KeT`A;GY z(>C=k{6A||sOAFcB(-x#rpP$S8?AP|}>n_H6%xQPR5B*2t- zfGta#E60E zL$kMT)wH&3SU%0M`_LqgUDn4`R1So6t`z9uq7#MOFXbGasgYk_4Y*@wrj_1jaaZXMw=^2 znkij-+v*F4vIW>~{$8lIXHm|E>4H0E`id&>O){JrWE^K(OuTdA^vuQC+HJ+Tv!5 z*Q=~fPYph)FCn%1vWGy@XQS7~^LtjGIX}hm=$TrL-7WV|utgm>Y$vtoTF#=H=o8Eb z7u9#4y}f7A%`=A|yjgtx&h|@PixwFyVSIGJb5_W&ZB=<4ORU9~CYde$8YOUV)&8b6 z=S!@X|I0bE;kH_Y$Feom&dE8t0$)vz%{ivRX~N(;Q}pQRInh%d3+~z0s+n_OMR(V} z2~`^v&R$fSyHEb?#Yh8gtyH=7-538k9Sz&I((&g?wv7h%|F8B;dwkH@#6k1rCgz9d zq#yOHjS>h**>dAa8@tAq4;v5mL@&E;;(S(JMOkOfE7v*4cCD%5UTg7dO}OmZXLA;Z z)h=$B9kNnsC+CcYnXA`sp60CZxa(-)sr;`BB8%E*Z)qw#rMxdfF4}-iL3u;Y+d3Z} zBfn0i{z{j>7frr3Hbps|%DsCj=WJN%3^o2Jc|D^p*6igM4szY;UcpnnsP)XvmNgsa z{JA+#_SRh9TjA1d4!5}GuLMaEjs=iYm<_ui|!_ul-yH&Bh`QTU*_KbKKJq0+5&$Y(}Y!zO*x-1 za9rk;d+4f{WP7hppzev=zr@>K4+GvkEDO8wOZ#cyyzKpV7n-hnYI5)CMVSP@+iay4 zkK^N>ssDRwzV3dO-jkfZgjGF{t@k~fb?QmUy(g?0&nxz&MbtbEyZ5~2-}5@V<Ajp__tN=Yf9brJmHS@K;%oGmd(j~GYJuIW zj_FTh+Fz#K?Eg?Qfpz7pb^rQT%e`LT=d-46+9tl&o9DgSvG29_sn@&wUT?qm`oO%` z$Nrw%TKDGQzt^Ykz1sb*|AgP0ZS#CNZ}MG^d&^Pz_H5$Yn|*JuoO`?VUq*l4{ld7H zPwd`3^Ltrp{$O?L1Nm!QZw_$1KEUhad;a{7BJ)2=?EfgW{v-GQkK*zl6!Sj_ z-v206|4D8BCyo7|e)YZT-kJVuAt%#+MJ}WDoF?|4P3u216?`_+|7<<~v)%d6R{CEY z<3BS!`0PIai);NCXZ|l1=f8O0|Lm{-)t>)r@cl1g@n1vyzl6(wjoAOyZ~fPJ`)_ga z-(u^(CFOt1?Ee?<=Kofr|GlFAd*pd8HR~5AOt_5J^Uj*UJ4@lm zOa+D}{~vArKbqrz%qsZN`Toa*`#-wne@^xP*?;~=*ZiN;&i|ZY|8w?y&c6A)v*Les z=KpH$|Fxw4*P8b~C;$I7OaJG_`kx#8f3K?lJ(vI2ru)BE&;PYn|JRoNzxUby*&F|7 zhyS1B`G5B4|5<4Nd*1y&JKz5~YX9p}{NK6uf7kBkJCm33@c+7m!!tP-G5mX^&+th8 z-$Va@&+Y%cn*Z4%_xk_e>i_>C|Noc&|DWgoGfeahQDbD{<rWU$xTS+12*u2mtKLCaj+GcPUCU9~JHc(vDFudA=Z*2HWrWBnbP z6SgVtblKWjp;@jsZAZ1u^Y890|N8GK_l^97L(RP3&+hJf5p>K) zntO-j&gChmChLYDyHh;naQ|G@Dz?9oB^j3%`%ZVOjVjH$wmNou+ux|NoLig!=UzWn z8*Q@t%wGNfVhx)`FF*9|R<^A(sl2#6nA7X*lNhTK#tU=9#edsae|vG|aMpRQuh!pJ z&N@+j{fh11pI@$DTz#ESzB=r~o#)f{|G)RI;_tPkv%d3dePf7uZ~vX;55G0jQ+vEK6F*p?5v5ATcYJL%Ty7Wwp2VZ=o7q^QV^Wg=m-mfpD4AsOpBxi>eK z^-->?r0blH@Rc)yf;Dw#re>S7Mc(Ul6;n-8nZGFVaqiw)ttXQ*=S7Hw_4J9PPW6>@ zo#HopS;^ymRldsqK}*6wdXE1SOWLCDKGVC` zb=$SfyG5&ex3{{7TUa~^vWa!Qdb=`f-<0@i7O5*FL=VJOPJuK_uh?q zSKU%F<)!{x+o!Li|L&_gcyzzy-vawI(bS-P@%dlRu-jF9%}BmreRXrgHIt3Hezj|v z?!6IGStQWw;l#cwfz|s;pD-u;|I$;5T-^+&ha^}}wQMVpa5UPgu`Sfgb86DUZAmYj zB+^!8-EV$4BVoo)i8$@Hnbi+@ran0=b#6nZ^{$0$4N_g?nI^VR4NBs@I_+@J+^nwn zDkbizKMu(1ado(w@bkM09$hAyAL#l@^1R%0*KA(yte7g9rE9)z%$Ci~uxpv|$mrV6 zp47bT>gX%X=G#preg(u;HJ$WM*>s}wL#Fy9%S6st6Orc7O#$wOUHkWJTC;V})A+=f zOQL#APaW#mG`W>iwam%$)cO^ZrY;bXh={2?wZdejO;hA)o!+DSLN8sKo+RiW@;%`;h| zmzwEi{3~ecwk^wyTr(rgPFyJaaAmo6*OU1wmRIbT2KbgbX(@f)5~A6-WTkuPtKg3+ zSC<%-dWDy2g)Pjwx~8mjbx7~3u;p6URy9ps9j}@lA?xY1a9gOxotA9#!b@}F+0ORvW>g%zAn4I>;Bbs z#WxcdE&3y}$U;ytuz6RU_}0Z@Z&t8d-;=)o^OX3$Z%g0TzTO@G_nZ0t|3~v1esVux zlM(ZQFV>l&w<~#b<&BOeanJzb8f2 zeCA32vZ5&qZBjMEXP(*@`GWoQ>Zfh<%1%Fjt2zBJPul0Iou^m)-8A#~nrB9ff1b%K z`#kIMoivNI!RJy-Qv7yHXfmfYli z>FK@e;?!ebmYVv$^o^c%dH%PO6~?@;0=uIwHs2~-RlO%Gbo;EU>&r^lB-&<2UY~W% zwCv5)wR^H-zt6h9|6A#XM%$c3@!24I6?TTtc_f}*H0`KtZb9+y+w%P7 z$4~1P^)=sltZ4r8L~vZul=!fq-oy_;yXnyp4^XKK~KCk^;`~J>zTXw5+^?F|x ze4g`SxnIR4(RC%u^zXj(ZU1$7Y2B2V+_p!fx&jHDQrKig8ewupx&r{R+KRdJkyv=3*``lIj`-RDMUyAi@U-`#>zq)wt zyLJ43?{ex_+&X{nTigEHho$k=cfS9x`@YQ0^hvAy&xhjke;hZj`*b_->sj;ppU<`b z`@B^C?~C@iUzZ)<_iA%}?c4JCzwfu#zP>uI>O+70pY_xCzOVdW{q=d>-}mM9e{S2? zf9n_ccg5CZza8F7?-bCnzoK}MN95pRkNv-xO+AFH#8tE;aKxSfIsD%y`0$7314dJvi&r};ZgIZz z`=Hg8!wPqtrJo%BwcB1J#>p{ftGdh)XO$xlxew^29MQ=+;$CvZqh}MV%Hd3BXR{+m zJ|A|-t2$)<<%roEBZDiPpLe_XnjF2Pbu>TP(Y|Gi&7WSoJ%_{Jn1rw4iFjifwWTNM zuxqr;o>-S%kEQ$O3mtRu+2dz2B|*jQnDj9|lOri2J2Z3l=`P{PjFHHia^yv|O7dA#qFkZ#-a4ip;DJ)K0VIfHpc@}j?dT7FT1m;q~>_= z8kfsm2cB+lsQIE$>(W;h<6LKRVoJ2D^_*i82M^^xat)g@QT|L%PKn3=+@2HrKD)M0 zalNmxG3t+V=br5jA#ROljvM@TpHkws)cIJgjE8xMr%TAO88)5`OFU=h>~ZaJ>tH!v z_{XYn3+I9w505)m3whkI+vq$DW_UmPOU2Ow2*ccDfAMlIq6^H zmFInQLiCBABS&SQoY*|aLoUR7%byclQoOg_IuRjqa%;(HkM7ewM^4XHcAE3#^wck> z#XfI8@aOcwGlm%|UQ2Aw9C0~wbjhBgFJ9|hJeE%J37X@6^38$ub9{ttJnOUFR(&~h z=7^7j^yvvxPG5Smd$-EjJuD}8rubgI;$8px^bM08=Ty!_E%v>61x_L9zNnI)&Um7MQ8b2)$Iioe_K)3?r? z{r|dSLE=Yv&_8bSX?i#vZ^1S<9wp$_=%b`D~=d*O})V08z6A&6wg|(M@Ir? zTKiu=b6$|w^omMggh`-a=tWzVlZ>SoxoR#d^#n3q^<~={$a?gm^xlgPSQ9YHFLKQ$J#TGWP)Cs_=`;S@5wp;x5vM8iAMp~s)cj5cOCKH z&2w3Askgz_^FOZ~)xLV^|Kiu@n~V>w-s6=f5+Jen>@nkGQ=YiYdUBwt=W;U77N>=0 zt)^aHr!%A7##17cue8Qf>L@4k-;+!tr|$KH$nJGIGvmsFo*r*mBi~dP_OmXAYmV3Q zI5(HLPt7@LrgEb3h2U;W1mo>sfBJyKZjV>e+d9uIeMkfHXzgFR10Thzp7c^ zQL71i!%jFw_M6_EDtl^r=}pEb7muF_fBYo0S@vqk+t3u-8^K>M^d^VduDzNjdtTtm z_4gv7iM(D4wuh6}hKu+_B`rPPu=Qq%O4w@Iuz9vY@8<;RY(3G(JEd@`|HZ7+)8Be- zP(7>r^yJpFm$tcH4!;sn^f#<{>FtfaH#e<4X8bm6@6_AdzV0>qyS=s4&-h4i$k$D6 zsv%L?ZpnYHC$70U;2d*N$0rH)e*;jrON^juc;EF}W#Y8tdhH zm-$ObaL(1HKQ|AzdTNC3y{2lisx?g53sO6q-%Z4VgtC49d6z(&;k$JzV*)8c=N#QhDuCt#NNt2q3X z?t?$d4;Y>$FvLB)c_of@St8rEhf-pGpVmJ3{PZF3y+rwI4;8}h^Sp}^kbC$@Hu$e? z(tg(Ks%4ML9>rC7p zo9ZPiT{GslOH`eftgQCXvhVR9--p_LiBGd1+NP~}{q^zx)~#=OA3ObfMImKmLs&n6tOGlroz;D7}1Ra;8!Em1O4)_czX3(MS2~3)d9=VPY z_^$hXA{MkZVAY+VO~^N4DEUuP+&Wprf1U;W`keH6J}-;YPfgLWRg>g+pm=x*^!gm{ z1&j?mbADZ3IAxWPAd`Y}f$;4%PBpKI23wUHcEo|M&$+&OiZ8dJSEj1!!7HnoCJTuD zFn5@=g16B_!edn)gJ!<8*Uql#3Ws-uS_|{oMls!L3iW?1nk%UL@-pM2u6@}%7u{%Q zP~+dDJMrs*lMGGvPwonCd;GY7@ru|AYrVn_Pak9P8MhbabzEN`|7Oiyi@3M7rcD2j z?RmM#ua&vAE7Naca>DmhuPkqYt72G&u0F*y0&m%wgvTnE|!WqmFOOs)!3@I zMaF$@XIf-zhV6_yg~7JRC+3x^7Kl5(Ke}vU5o5^JLPQl-RF7CM-IlZbN`n&2cL+2Ogx(@XVK+M?j6FWudycB?*&OMT0F z!7G|$;>NJ(inlK$of%9#=ZbhLSWNBYaNNe4666vp+92H(8rzw?&`dVj*=|W}$>N`B zcNa1hwku6MA(^&fKHt%!3zH`|E}yexHSdzNCA_H`jxnARZkcWMY6=f#Zwd_2?6DJK zkt@#F`@bs1v9xO1O&4|3ua~EmJiI<((vk;DQ>Jo?N;gysh3phaS?%Pdy_ChX=tAl2 zK=FpTj5$V{r9vWUg4vtyW~9vTY&8|kNJ}zLO6iQUe$@OhPF&onqISpH-4dzhckTUa+l-N#zXrd2RC6 zM)lXJ*Zj%^l9mNkNY2e#_$Pgz{vox(WfH8N`FU3T+m;8c`dArNu;=c#6|Q$VeM58B z_f;3=v*@4IC|#hMGjZCqiJLTCK0aJ7;ACIA?ZZ^g-nlmpO}HvPcas<2m78g2vwqz? z`sb5-e&h9RCj++omi^EEdcT4FUFfXvRq6A*{1wm5>)-A*CF-zh@9RicckaBO-c#e} zeQ+wt)U9-Mn6o_FE9J|YpD$M|U$@IQdPd)^pf2-$zus)A|MKIlSAE>>XNM;3{r&zx z@)Q@gK!N<4k0<2i|9r|be*fq51^4*vU#^7b|NVL+ef{5Wcgo-Y{r;d`|KE=%)ARrR ze6f7}zh7^*zyJ4pxBG&ln~JYR{-5*ZdH()?KiJwh95c!CE$r-8mU*sm-0ITDzOpmQ za^F`RxBK+5zm7*mfnD>2lhCILZ9XbW;ww+M8GQmBm!#Ogb+uR3+ey>TsHmB*JW14& ztc7Q0sFZV_gf7W4XxBWGB=mX4F&{Of@|gnK>n`@3D^V6(+UaKIc6rw2BWb1!XZq&l zZSK8Qqh_&J^IZ0%$+Momd1i4v)30om@vL1T%WS4wp0B#}dEU1(>UQAclK3W1+QG9z zmtE^ZlhBt1Y`z*!;;SyS8GTvEcQn=Jr)OY&)RsjOdsny_pSsv9Wz3+>qizK{E@|H7 zCGY;ES%?2RH*M9Gr7vq%`XqY=Pn!2-ndVweugIvQomE>FJN#W4(Cl?(q14tDjVu~2 z)1?BJS#4bzP`fH*vDej=QCAn(xxNbAzUu0_qOYstc(oN~a|X|Tc4dvtRZYLkUDr0f zyRtUB^>yTZtION2OWunk3=VH;!6u+vxpNJ5gQx z#;Krho2Gr$_INuheA}a`n-~0DlkvDJ{8Cxix_PF#8KKrudv|^7oza_{mg;@`=Kot| z^Sx@<?F4ZI{v+I`csZ(CRNzAFyqzWdVa`<82G^~$#I zzV)hT`_>OJ>ndhi-}^MFeA7-qb*PxO8PUNpw=KBTeHnb?;IsWC)_d}n~8nl_OdB~@I<)FyC4IR!t zNuRC0@8;n9&=j2WfWY!?O(&nth%qCQBT* z@hj?|dge**r}ATlVTDtoby6jwe;$o!`!sdyoTt~7Do-ZlZJKd-PrS1D&ogO$#e5ne`I=IW zkC?3G4+-qvXCyAL=h%@MoGn}8c5j{AAF)1%^Vj)cyDgh0pMCQ`Eo#OQsau!cxooTv zd*fLhb1UIZ_3GI5zizL6r@Q!wQ(o+B?n^v#Z)pk!z4J~sVtiWGz597?CZGN9>9bG7 z9GunZe=#Atf6J--+bce27D&tNFSRp^xFa?L1L+Z@%_@zwNi#)7@vx9sl)Ov}+z~o1?X(`p*=@sVRO;(H?g;*D&uslKD2U z_|9%;uKle1A2WsTF)_SRFt%LvAm+KH`?}TazDJHdJvE8h@xX=8!VD}NES$^u8H8?a z)^1W@)Lnht=knRDRc@iK_o9kV6gNyTl={W_mF1ygbPzwwquKv#bUgp=*gRE8Z0_wl zRl9zz_V;r!3ou?{e`MdfE&qkzl?UIjK3MnvB=_0M8+j#9OZ;V?Uf;B%w*RT-w25Uo z8{Yny8>s3Tu*rU()AC7;-nj`swimo}m((zn;@v2m@u{BWVeZW7k=j=pN{k56QtFPCx0k8>6RbFBl+8W4nss7*T1I2na?Q04mA@3_Z%t(2sF3>4)~3PO z&T_epd1ChB!pg6qTs8|?qXbx6B^cTySUV~@I(Kw*{lC#M;Y7!zA03llbWD@zoU($I z|A)KJCM~VYHieHnS%kZ~7wIo~(b=Eq&*@%Qw?aNVM0I6*iN>WydqvhG53R#GJbeqR zrd;gun4seGsd2+LnW*5rs7ua{HJutqfxy>LtxSb=Qm>A%E!BGOxPG3 z7y~9SMhGz6ztQ{nMX#g)gJeM8D~`Tr9(`|i^u2r0_mHFiZAM>217oBC+eH!48nr@G zmR5U{!nxl~53EQHmhjYeZz~Ehm?Uo9(5U1bT>5`{SLf!|l6Kv4b#>SpE2-0{?vEpP(seTm60EhZa9PBy8WZ1z8LvO(u$o0*f1IT_6* zS!ZXse>aTQG4vPW2tB>r)2O1=4#L`jNs9%n#XR=cOU zhS>2FVuA-WR;Txv%xKyzRPt<+XJ&h2;0{j?!@lPgeG(0QcUMfWtejr+b9%+f>2)`! zS4+-li=0tEb9&dw>5&gwW=-kJ+$K5kYjEfdvC@#4OpaE96Qwp7H*`AH7cZ|mNutJCKE-9AS*-6Ln>q(fUOZ@#GXcv4fP)U4B}rpKP#$MQ_qkJu=yU*_rjt-Nr5X# zfI&)uD|rHIRKkLzj~DQ2vGHhewa#FSoWLm3wLnCRt0QA!@&Z;lEiS1AtnywqEs_2A zJ6P>jXvZziGCZnMw?lZ>LV@gJ`56~0_8;>*$uxo2ZKn6+I_>nC1wZoTPKxX{n#7VZ zagpcztPU~O=?$J2*w`ej5cAQi0uVB3v?k-a4u}h@1 z_rIY!=R(y9%S|V2=L|YEN%Yy${Zpp+h&IjMD7hpkZ`1dhODj|sRH=R`>ai1?{X$6 z+i{f~rN!CdUe;QzQ)IvPHIsme96)vHLVwtaEuM4^A%mN37rpC_ng)|~r5QB&#? z7oT%mZ*%>Im9y%EWRE4z=5L#Q(6UeV*ChSSwTu&3#bm|Z&hw)(UcVeAxv{yT|AF(>1w-{yXbq(zF)Ikm9?}b z7_PpU#y7DlL2;gbNuy^|n?;A%#qi$La_J-C=G%YOEc_(%HB)b{G%eGS$9HJ<$e2AixWmfAg$+qNz0f%t?MA#!Xl7ysIx zf1y(B|8eQiMxk)Udd7^Un@%oa{%*nM(cp8j;*+_7$rje-)+Qp}la^WTT+X;m;9IjX z_nd93CtF5tmaX3XatB-W7S`^7rTqWt>6>X{^`h*^s*Wbj8BNtQx+@notlZb~a{8y)+nKHQ zmuU+G2CQ57L?T8r{-@b`rqT)hA`*r_jNILeTMqTSvr?ZQCU>z=`QdgU!xu{rMrbby z&XSGLUF6hVw56NXaS!)(ZN1|M!o3d6xK$r&q#y5bV478&eT2BD=p5AUAMx=-8V z#3eP2Vg>6pns#R-Y9V58Ba6A79r5?@4dBV~i8*R5612EL zHTi*<{SHot6Gx-}98I}!G|A^!tj)2+nqz5uj%K_$nsw%wedCUMAr=jc62(EsGm4LA zBpZ*#P)S-a@Lsp^=fxmH=bUy z;Pjq9!h7eO-Y0YBfX$iRI%kghoY|#xe8ZkI=543W?KyScCVH0FsVjd@E{r*QgC}ws z&)GX~yf?AW?OJo{qE7myok#vko(}6d_uR*YdttYO;_3A^r$6MJyRhZ#`Td1lk*aD&k5hn z;kbK2?(RYPwI~1QTu|Y?pcZ?n{pv-|y_XcvUOKz<$jN1A3Ukh+{!cjLRdd<2<#PC( z%Qn8@R=!d8duwgy);jB6v0m#dx7^q9ZmsLtEADI01@NBUb!V~O-AiGyS0|cWVt3_Q zY```9!qo)XYe}}(Qev;A)n3b(do644wVb=x^8Q{ckiA}Hd%Yy~dRguDiri~~I)Tzl zIimzP>t$~=*xqQ0z0p#8qiybuj=eX!?%wG6d!tYG<^5@mW4?Dz&AoG`_s+T6I~VreIj?*7;$5^QS+#c`?7h2Hm&~~x9-$RXij}+cLQsR58^6!y}++$O_$7b^$TkLzRUia8`-DCT6k9FQXcIJEHs`o^# z?up~RCtmlSc+PtgWx&T*darWIJ^s31kdAI(g7(fRJh#_vW9ww{RUNW5NB!b~onu z-Cw@%70bC-EC0Qkx9-&}z1J!G?yZx1z2V>e?dx8@n#=ia?#rEeueS8PKEn6v@V|Q* zaS=URS;gPoYS&x)QuapOTF&SNoQoCKUU>JcW$w!znr~Of-nb<7^5HtxI@@=3Yi~W~ zyK&>+b)Ap5p8R|Fy6(k!xf?gv-FkKJ?IXYUzv^CG)LZ-F-P_l7@1Nd#`)u9&hP&^7 z*}Z*V_krdAJ1+YV?D21J`F)tR;1;t!r)d9dq4-z)_upRZ`@ner?W(hH73aVG%=hub zyiZ)`KU~cFIBDIdym_39{~Np&djCZG-xKrspN;-Kj?7svQ~GkB-RGrtpJye!eU*4PRXCKQBsnYZU*^*#C=b|CbPZF8BJk!Sla3^?z}eXNbQ4l2L#&JpP@G|F?wq zU&GFSnf2jIbo|$t`QKvgzeMi;7H$6}$NsDP{V&=3IVT=i82$fS=KU{4`@ebmf6MOw zo_hX!N&nX;@9q`)f9Y8N!{h%CxAWf<`M=G&|269T*HZZ(uJJ#N-+#}`|L*$#`?Y!B zyz5_Wm3wybpZ^NmPiy~vd1CjO@BXda|DP_$z2fVi?VA5{tNh!?7k+#Ge>Yp;kC6V? z%kQr8o$tN>@5BE3FK5?tu8#kyz5X}f|KB(L{_g&N|KpwhKcW8buIB&dxBqpr(_t=K>#vZkkX6BJw^^;woV3g*YHLA{NmlmZjkC99 z*;oe%N3E6Fx=mF(OgCaZqgkQJ$%0%4&sj#FuFfuf6SX&I^QK&llRAf6c%J@RDy?}S zVqe|bYx~;RXO+G--+J%hr2pHVxK>oWdwEd7 zRP{;4$1N(8#r{UU$^P^4XxFq_?)cR=Dh>snO4U3$5 z@@hn4s^_(+%+Qn9VhUADL*wKBmR?K9mo2-VRLyD@nK)H-T4eHE*RsgW*>k_$%v!v+ zbh)Rs@2g9B+Dor^d&sMnYZ-~VZVxQIoLU}Re!KO1Y~|yn<#E*ypRSCloqwwTwC?|} z-|d-}s#qM>Tx_!AfvD&eF^4Y4NZ7l+rcALb*epf!rU+3SOo;S0kidW#+E` zSy$Ig`<1nJ^Rrp6*6w{LmA&D(*zPy$&+`4w*?Ku`_M5GD^Q3ckJfF7v-S)SAzw`Ef zem48v-oNjpbLX|(GJEN}KQ=3EarVDSQv+q%{}i57US5O!xFm1)za>|L(`!qwN4Nhi zy=fW$=f#YC{vSbi%K2xM->+u>SN^bBy{_VMxBI_}r<2p`Dxc48k8?OGxqfcd>($5q zRlVK(ysjGgxTNV@GW~e#ch!EoA1_t6{As*>-S6l7_t*XTetm!ak-y>h|FcLqFv+cJ zcz60f1Mdz8mQVhTyetaLFTWq)l-tlGC*JQEYI{o$0hw@pSHtgneNn?K52TFXMWFIq!oSAD|k}TtZV<*EHl}B@?>__<*BQW zJTu>Y@@#I?<=LLr&y26$^eJ0+dG6bog?inV=M%3@p7(uAy4}Z}=l?Ddp7);Rg*~fO zKvP%eq(?f>EzWxSwU|wr#mD>HSyd{qYu@HXJabpLetdqusjOsy{MQT*WljIuX;bE_ zn`U|i?h2|c5?P`zn(32h6|CqcyjVOo!%uYA<(6+Ei=_5y29&&jG@6=r( zv&^Qh^iN&nwe(cbD!;3%LXW=sH04#uI;X3vW1qfq?CuJ=+goh^#X8$!a?_QqpSA>N zmPSRJvtIw76S&UaR4Z0|Rrq$hFYC%>SF>&Z6?&*?>xTBZs-By@_H6R|8k8F#|37xU z)`DZt@-|L>nwV;s?PK{(MPr`qsr0!_Q5W2nZdvU5cCnv!)U~#2TbKC0%}(aNeQn*f zt)`3D=44w(-+Q-h>$0s~xvkQ7?!CIU-Zzrrtj+wd6T9OT9P$= z|NprQYYwL0{(bBEffKd`4o7$0{a)0y$K6&x;HTAfj_Xqo-Z#w_yT1D*XY!7N)wb&! zxNRP>Oulhs@|XOsjZu+jnQk1_YBLPB%AW7bGd<{>?#G6iyOSRXSsdT5`fj>MVDclr zrTgrz&GS9^|CAe}YU`$$PkB@Hd#-)^cl>0)yrOCQcRV!qn&usTZ_1R1+Rx0cZQ31V zxF93{&*NK5cLtm7)1582C+)$dnGNb@@pbWgo=be6dA@$$st)gr6dB=Pnl5H0^EReG zd8oEa-6hPV>1fM~S9ZHD7WS2V7qdxIa}V1a)%L;6{Lg+q`aKw{&gfTsOz`Ti1)&zSVqvlb*zU>&7>i(v7S)UniLVzJC7Oib>jQ zUIkR&xD~xbd*;f$Z~gNvuU=;R-fn*~Cu6(t$#ZVsJKAe=%CvXieP8zd!--hySJ$j} zeV?bduQ&UBjkfsqU(depW3>D5zm9v)gPniBALPs{Xk)+gklTLyceB`Bt>7Dv^1psL zIyv`YMERV@^4~Wd`z&76(|zZ$%6*GdiM*eBrr&ugTL1G@Xx*o2%lAAp+HZOGruF-x z<+q>P`seI<$m=tE_ME3~^M77UUROL_e*FvY@L!jg_7yML{-@IBf918tbzf&?S>L(- zZ`+Q)hjTYJ-@QG@&HCu!zQi^8bEgSD-*r26SKh+wwJ*w!|E|9+SHAD`-S;oF@7yxi zySL@N)!QZis#b^hRm^{M>v2!}gBAQg9`M;qd}>}_8pzv!Y;XR0^$EFpO9a*>Utqp+ zysvD_1Vh;r1H)Sx1?)*LL+>YFTs*U8c`R4NOFNGfdjHoSJ^cIc-PZZ#kN$7`e7AYa z>uCNdPx}Aezs6f@6u7@iMqB2@#dzB{uNVLQJlE{&!Q6kZx5vM%E&ShiNzeKWV_D^D zX8tnt-msa?W$n@*b}vpHXk0vnOU8t4if+_o2j(rN%AXxB zPTIThgTo)jwFaw2Qf}>ibot=J#|LF&%#4jKytr+jPS$l2cB);x_jR}GjahcDKdn*D zVRhl&|EhcQr9~!cXY4F?9lGabcdOdz)ub&KW;wMTK6t=-&$sG5`pgc?Zg2Sic;g-} zZPO<+t(H~qGFxJG`qBKen-8ygeE8eK{fsHjvEF-lnGUf|aZqSpE4ak;jrS3eCHveD z&A+?L#5HA0{=!3wAyS?tF3-Iext?({bzW<1yyay#mq3pE@$R(}F^gnumi+&GP_E@@ z_!oT^AH~nXPTXruqC?bT_qfU)-eAqBB6i2-P`A~=-3I@v zjWc`>f9ZA$NIv-Evla8Aqx+@jCw$qnPuTs>q@xig$BSItA4?xE{o+;@vas{ATUN+! zW09?nOWada_Ul(UXYV-{wdwQ8o;RoZp17@0IlV%~W2uYxvXIk{ioN8ty+0azUo|?lYEBo! z1+QphtB@LV$r)xKE-mzw{bIs}HEk3gx zPi+;k3qRs>=jGnBF>8+1?A4odCP2ok;EdI-C0z{+42nNlSXmiH$OTc`bA)Cv?D)7c z`b199x<5OLmmgkiet!$=iO;Xyrt9BVa;PqT^PoBCzrPYg1H)^kB?0$CVir`r<2gI~ zzEhmT*XFN+UXmGhdvZBNR2`2w{PvvaQq9DpA<)34+7MUCp~Z2rnf2>*`KrI1E{e_^ z)5_yqWQEKFm=xtgWST!N)Oh3|eeC-}2Gu@`30=l^AuGQ3M2x$aGrpz&Ny7F+c+_#&L`h;0`E^-wSx~X1o<#j}DvYOPF<*qJF4GA6I zlTSuS<0#$d0B43a-@s!y27S@mCwam|;VT922pZ1b8h zmuZU@>-tr@+y&NgrT%!af%W1Hft5Riq6Jz#*4>!bFRFR^#RAuTkt^L8JhEPF5mj2f ze6ep)L|pCkxUVmrUTK!Al^&dznZq=Dh4}?Ftxd;OYwo<9^|m?r+KvZXJsBM4&COe- z;X3E{&Xd|p9yz6CIKRy5$aQ}E^!c)h{#M=_cQ`Dt$lNATI_LS?n^`^aE)G66pEk?M zx2C!>m1V^;?eEfBIEiWE#SbgC?ppQW(1u&vje9r^Jm#HR|1B!!*r9JbREdkA%=?5wdn8e2ct>i?Ga zRYxK{7t}^p&DtvRC2(cMedddm7uIb46d^y4r)|&Ux~xfW?xzHoZP#;tTebaexojz) zp_{OO&+JzdQzPzN}&+ZpV|Ian|pT+-|Yh6EA)~n1#vU zea+_!_W8Hpp9;S(^z|BI#e?_Kmp_h7>k z>;M1#{rvp@|NpoZejO@b^4WH6GKb9qCf*Yc?79~kd3_RC3we?~4oiYAh}yG|ulU4a*wlc#c_onAGae(CFZ zPcl4`G(|0~m)v47y6VLZ~>LTT%>Qh(Bq~#r&c_C(*$!yEB z>0!pRuAEtBy0!CcZq?@55B4mx*lBq#f12@}CqBz9uX3JExb|u8k(TAwk3G*<-YTB{ z=}5Zm*O&k2YLq;mAI7EOfVtvi@>2cMl_rT+!83ydK1NRdJtBUEj;bKUa%&O~QH4h%zy=4ehdP62-eCj%k^#Ja%Vw%5Sfm zC)=*fmpHq|&3yIEb4A}43jJM^?yi07Qs1^sE4Jojg?nE*zRPUmhSHoIY46+D|DXD{ zZR=d!?DEyO?wN(ID&ozHZS}tX=w4`leaz~}>8tNN+ctIWh17S&OQrAn?Yp|;h^<~( z`{`IaFNOIBo-79)LX+Hgea}n32jfSXLH^+n*8(3`Ih{; zkEhdq?)v$EUw+(X^Y7Bntm-3IvQH~$`MxIolGTj!4eyE z`K*h5*WNEwkIk6KWPWy6oDxByoxBo6ddRm^=5mBloYH=j2|@U+DWb z!+*!6Dd)BY8^5PoxAbuDZftzI8vl+=u5;mG?QWZ$A|6`Mxdt_S{3?N{=+kW_E`Exn5GX<5=kJ zH(AwpZt=`BJ7Mov)Ry<>3IG4?J5Fit`_#Gn&!f98JI{n>zYlx<`jy5*dDADTY@ zxo^vFb>Xg6${h7OFSxfqUY(o!CfYdmrk}pqmD&GJPv0){EMotzYlePbS618JOmN?J zGk5Pj_pWdIoyUIVi*L<6T6+BV-Q9c3i}wG0AF%f4#bUc3 zE${CUZhuu=JhJjL%ozb-VUy>U)dZ?(54MyY_rp zvfb=O@VvL@lI>p4Zr}TQ@~K}}ABTRJ?7r(A=Y98M+WS84E4TU3$p7=M_q`u?Vs~9R z{IBGhe*K&C`Tum%{cCSV|NpwL@7Jcq=l{N~j{p5z)b{_YQ=KSq- zao@tPi&tla)!uM#l+eiMUr{gMQTC#|Ze@C%yhYQprv<@9O&yb)qBk^_rYZ0oYWm=) zzVK3%Fh@FblGWYBM#+fg4aXZ79Ixl%kbm}3;aZ|>@}>sHr>b|G)RsL__f}8&o94k% z*pShxzrIN+#G@fG-qnmR^6!0_))q+txmaIzED_i{h^j~$7DGcD7HxiGEMCG_Ww|aqhWjGVhNRq zPQhyhhnubA!vu6*v?++ze|N8cP-eP!Qgd;8{el;Di&k`fJKlBcNwaK5X5tOEYU7sk zm(1KQRmVJSJ#~<^ZijQR{ss`J7GYA1KHeKg>!)aO5_I!Quu+Kh>+ z5!E*|e1t5$4=1ZFY8F`k*o|eP^^t>Zk8W55C1@;E&i8RrH>%V(DN{9mX;Jcjh0lb= zhQ&WTm%Om}y~AW#M*I60eLbrE8*c zZ)W$lAF6*0Tl_RlGhBM)A1NnU_HDalqEVtRbV#=Nk%_mka-rnpr4=4dGp8@@n0(BS=t%6Uaa0+S(Sn3>9p2&SMagv3$pu*(2 zE9Fit>6?3UW}mUbqD_;nBUK)6oat#W%lq%mi^8TE z9o-SrSRGZFBURXv1l%VxXrIu&-`c@)QHdj=pYx-_8s#Z0#nbONvQ64By)bER@8;%ZTq_)^JRHgnR%liH@n-YggA7F*0#ighP`$Cf; zN`O^LfHm@g|EW`JPHQooP8qqX$&)hlw{p$0 zvz%@Z{P@2=OY8S4lSs|LwF3t&#>f%nGmV4Vg>ZWeh?5PX_JwXi&&K~;A zi>8*aXp~&iT>EJn=kHnjizWqMF^SXKkju3}{?)Sc8XkPsD@3{1F3euDcmpe+HCL1Z zt5m`omWjJqF79Gl$ogvKJhq1$W(n-td3r^F0At^;js2@w10`jRlfw(6wVxc6b6;Wc zg;C9Zact}5td5!Ocec-1e0lr7o$``K3;tGl&pWJQ^S?+-*W1eKwDu`aZ=EJp+tsU| zStxDhvh>`{DsxQ5v$$n<)czciS?e{|=p3E<=k*rd<*diK)}G8-8<@R@{mzE<-U9zr z4)AFo(6QRRw0n)9%)v@4fpl$wdszZ{tGrFSbKEA&IsDe154Om&SA+^JrX&U>2yo>r@7OrTjpIdSh_;BJabvZ<|9n6 zk0eFS3)mifz_~qtQ9LH|{>bG=Z_7Yg0~+ysCD< z#q7YqAmydEWM?+3-FX;UsFSF}Ehw8d^OWL5(>IeJW^dkL)z{3UeCKyvZlvK|eHqQ-?^O?H7$hx<1%hKeZXZ|ic z+%j9GE?YbKr(%ewTI9S@uQ_F%^ z0_%6lUK5&la-%@k?p05`+fA~U*+s5iVmT}N(*6l^W|`mA`?7lV?3{^nWA?9`IhkQk zbM*g!t*kx=+0LxJx@+y3S!+*rt+lSbY;|@8^TvbswAlVWo%Td3$RRoLrC09jSpp0% z`!84Of4FGGD#&>8;Neq${C`UwWlGi%s64lW`DkSDGOogN;+-;Omo^Hm-dJ&J==nLdBcohnPU)3tZJAK#I$T3BYbJ$^+o zf2{kw<`y4`hAxxVY5$+xbz;Bw<d(VsK{^#CZ z>5EtoMufi$RG8|0X!cp~iidaiS?=9ulY7voS$Vm8c8$m06K5W5?+ofs>)Vn!Mbd)n zY|F#N4qQ^_9zK8hQ2O3OrGF2Vo}PR74?(>XKQZ1pCS zch|*Qr+CclT70L=Uarfbu4{?U6X$o2-TrlXpM8AV@$Q+P0=KZ<$|H9_$2@)Vc4vg# z48Fflg6=(y-S#A*FG?ul;p*6DZ%k&t&3HE9=#xD2$5#KIEtpznY4@D}*t0~vhgo?K zn?z+VEb+-&bt`4vld`%Zzjsfso_!kfzwYVdw>>vGJ=Uy!5$4w(o%bT9uZH_wjo`gQ z2K!!~TK2rME<(xY;rS=e=j|&socB`y-lI8r2_^SlT?i9j^!7!{hZmuY&$s0jmD|0L z4_oAZ?zPju*O}k$-*MzR^6vGbIZux7%hlh-M@Dtt&g|=d-i5s-BP=EMP{}0 z{bqmVYiymD|B$P7?y{9E_8-{zUvkWU%eDVS)V;SC&b>WV_;S{|kmpe!Me0BL9DUS# zwQtS@Uzz#uzRP_Qu6U)g|JB_UuMRzVC$x=q=W0%!|DSZvf7w}J;nSTD~ zRR5nd^MB5h|FyvW*RuUTSFHcJO8?i&^S`?H%Qx8n*cAU`i~R4c{y(; zVeXyxe|O#fdrSV`1AT#=(f=Oz|9fu#@0tF;`{)0?rb(p3RXyd$q;i$L+0s{ps&(bMPTF z%yv2wACsDbmf5LROw9>9Ipwh5x2kPLX{VFTXQ%Eu`QpXmCFyZzG=m|B(4?IW=63H< z|DU~hkMHg!?zeXrfBq)B$K1m+O3cmVUqNC~=h1%s`nn00pI)4s>b>4h_E+iavs;s& z|J!T*^~24x)7{_y-Sg`UXX9(RzUUo)etdX&fBX5s%h~@n{9do8>u{MxMoVa^QuAx2 z)j~0=%vi*0E*!k2Feku?RX69uA`a6v1zIe3l{j1uI4$WuveT!9+f~RlW#bXS_?(X} z60tpoMbv8_rBgoZTRfQ-Z?oXK;_s|lx@?I; zvUb;r1(%$zE>+Sz;k-%Z_nS56&SjZ~h0D*Lx{7Uchlh`$v zukI#=)V2$iYFzpESwEp6e75eAt|b##cJwZ3zUtt)#9)J2hyE-HmAh__UL^Jkv{l^x zZl@&?#yF)(BC*jgOylO?6~2-Sn_}*L)6Gg$oAxYwcIeM%Q{tydKA*e&!Oisi;$@ZH zGr5hdo^5Y%vx=TOwax13;z&2k|B`z#+$)UEn)Vbb<w`u6z*qhE>_`b`8)8@@VR{lVt{mj$(@(;> zVEYEoUFYS!ynMIb*;4y0|51>0yR^24^5Z2lb{fuT?(NP#`_cU7X}(E;Z#drF8jvC5GK+QMG^_bP^U6eLdK{8)xAra2iR@Y&A|$=^h-YM1@ayJB z!q3f)GHSYNAKb+uc5|v$=~C73h>-3tGRitjGnBetd5H=vUCye#%DW(oC)x4XjAM^v z`SluZPkZ^{gz+QG2~`J`<9ry7JKA%7>Z^>FQYchwy&x#mRDZi$(fyl)SJG>R#h)KD zc0PAruy*N0lR1e#b}M&Z73?qFT^z~l%W=@(Y;Ebx$%mfsbR0dta$-TY&*`-#Cne*W zmQ9}_$g6pweP8{-von}admg-@wo*@3Fxe;N_RU2%&8!U7Ew%p#^A)|?B4;(RUH8VD z;4|MgpJlOgpL1REgavPtZ=1=4*{^)kcN|(eUH)G{<<~Vga~f_K)SBhY7iC;wue)j6 zCebV2Y;*hTChQEj5pZ$glFl3LR$H`{GZ}@PrQ}x`Xay?NzE~tGdcW<^^~DEYoLFMM zDX=f+%HqkBrWk7bYW64Hx+JpG$LelYj9=z)aqnYCm)V`|_HDJYIbM;WCHL@Za^Eej zrB-TT3U@Qw`&^c&22S;OR;|%^<-5zeUHV-Ow7lXz8&BSJwrd4F01f`ak8n z>Xf>ecRyKJ($5;bc~?{TkI%l8mnGU<^*XHEpHYGrj9OIi{+WoIEV|%J2HF+bi|T z)^pE$Q&hhD@x{D~z1ndfKm(?(>npC@7I^BkeJ^`!zSHd2aoc65FN~CZ7AUuE^@odE zi*GB>Z~bNcfPp#UfKaHQPwA6OtjDJuHhDr0@|X#7&4x}$qK11H=()2&-P z%e*noc>2tDGYuS@f;nXIt%%{0$uI$zxPdF8`B&qF>-UR~2x zIzM^sb3@_l`;O;mtjph<9sB*4eIQ%ehF060B=P7Q``padPo0~S#_Vx)YhBrvg|=@m z{*hF>X7^<)>w?_8^xL;@uzlaQU-w->Ip`=L^PQmce-_`q`@HS@uKT+0E4J^x_ikC) zzNLHLXKwy+w3oH`0BFU7zVM&>@#n-P?=AY!WKeUPyS?JD^1lM`AvEO`$JEyqb{ES$ zmS(R!?)r|3|6X~a za$d2~o0#Xe@>K!vbbX3nv)u7hH@~u8_p4Lz+?V&aRb89CuXIiE-RG@0LfF-J89)eDC{PH|p7I9TYx`|H(i@BjDz`~H~b{oj=jFuLsj7`>mxYc)xfb-j)Eme+**!^)VO%|91&gP zBIdKhImE^HiHq}}PDha=0avCg=Nt-BIT{?|6zt;~lCxc%4EN&5NwuP#=sinAvnjA}VIhM+EEUjm+lg|;q9yk9bmTo);0~Z|0 z+2WEr=Sc3CV|gqG#g-iNm2uD9vQzuWK@FedT2q`#-#C_Sv8sB^!f1J{if1>+kz;Hu zCaE#IYTg`ADRC-)aqitVYAA4;&D%Bh~<3#6`qyM{)oH$r~ zqPNC_QN%4>*t&_ z&)M7m#gnVWt!U1%g(9b3Z9X<3+PG^DXV($8D-SKEt+7}kW4B_7SKc0%uIy7?r%$bY zVzI8|RMQ79Mvl`P)_85$;x*&Mk)jaqMJC6!7*Eb;Ir?_;@oZzyInCzNWp;16e0uZc zU3+Uh_kXe4qhnGp;Vs`e-nZ7k=?v(7GAtO-r z`t;`A$Nx`e>H4bev+%>&qb}zTE;&6Z<=oO3zvnG}Mk`J~o8tFMgln>iXWbO@yHCzB za-Q0@#4Y`@?Z+JXPcGgqlf9-Lc3V2dZ_*z(4;3-59{)L(r@wDG^^?WDkXzBW#Os60 z`9(`?rF=XpY)+J^TwruP!C<(nzUIvSHQQ@Vtl#zoG{m@tshDu-dNBJ2v_83@`6^)X z4xig0HiE4le{3v;dC#g8Thv5d6uE0Jbac{fo&#xr0yJB^wX$|D_;JMGb?4kAR;{}` zw+L^Sk-fC#w1})}kX)(L!6Tdwch0HvUV7MbX^{rU)Gvn@ZMdZA8l?I5r16@IrF$;E zk2(EWCHOx}!0Z?PFE;!Ce`tL1*BO8Fy{Ck7E_cqp>?z{DP3AJ6kN37Om+bCdh??yE z;c-im^p!(XF68XK!1dRTBlODQ7+3ba$CKxrk@j`oUc5(O>O~(_XYr?3{AvSzSwnxn zJ{Ne`{LAD$44y$crswvs1WkK#X<^3|58ZQ5T+Zkmy%h3y*Sg?P_uTE%CWm;bUey!| z-gf8mBA;NJwa30}Ibt={>rcqF6)8J5-I4LEx#qnl%wp~3?$copy3bqN2B+LTB4BmR zSL6Z@YcQ|t_2SU$OtIJ9?w*cVdiDO4s}*wt4s5v|HTP=y((rQDb03Vu%UB~=e6KhB z4R1OUBJp(VU!KtG9B0M0TKKo#NS@5u>U!gUSM0Vscg*DP1j?|4O@HEPwZwD6(QWf@ z_^sgHBIkQ@!rhbBdoJ~Vy*M=|^s2C*%iNouyx00vn-l+DGuk_;v($3ViRlY^d*^=* zGhVyXv^MyP;@ssfflFO)S=8Rj{kr!`VZ^FZ%Z0ty=9)&`*1opwOEBx!&N@b^xwkKF z+;@s6J?{e>&kI$b>FFaF0S^rV(r`qT(^7oM)H^LZGP*qaB0MR**mLM zZ?!&M*b#c?Xsyv<-Iyb-cN!l0wU&f>*PdP~?Y2vmE8aIWzVzzWr`Mfrk7>D@E0^95 z<2|wO=uM4_Q4O!77M$(0{TivY@WwUYYXUx}&d>2^Pu-XERr3Em)_coaFaJ)tJn{40 zR^?mQS+B3XyLX}Kk)pqsm_%=liyFH$i!D2(^q>m>GqmhG<-}j4bcIw~s?=Cdt?aY23Ww^zZRb!zX8D z)g0#qeY$+nuI-8KwdBPYZf-ks^xt~z3+FYzx;bSQ!b+MXLg!WdpqZG+S#x7J{8AjA;L-cU){ zB$mMN?*7(w$y@54@0PpO`s`KvG>11T51+@pT3Qwqwe>~T<`=?zZ&tFsd83kU>2ovw z>gzO~ml;#;YDs3Q)g?X4eI|MB5S^6sUbJCN@7 z{H^MQZ&RPW>dk#48hhdCojqUf{CBfEr}Z-FuG`H|*+B)7g$fAU!*iqrk=RgDfYGAoBuv4Oy7NL*_utPpSD{c_1S68R9~p8dFg1$w$;iR zn(psD*&Iom7oGoFYo>sj-hIxEUq3G5zOCHvwAr-y|6bMGCiBf$&#zj)%$4tY%Ii8_ zzh%X5#A2lSizU{7{*d?aL)aag{hw{Oe}2vP#ZmmrTemwWU9a-Iz0lLEqjcTz!r7w! zu#_;@cPg%N+07OatchAL!Fm+qO z>0_Uh^q&3INweHu81ye^a`H8m?63>-o+qt;F8?T2m_IkkJ}_y&>;u=_sOt;3_XkT} z|12Hs_Uh&Jj_YC{fOyoZqe7AhA7hQP%Rmzj1<$K@ni+LOK`qR&EZ_CTy zK3tc#hc`aC#8W^2yMgofr()j~)jv0ye>SkMP|VN!;ag(!zMpr0-v7AcA3EhrUbhvu ze*N6D{Va;33J+Rt9~JHu-`T>oDFo_t_`Okdc|<9U^{%YRNi{&)8I zzyEKWZ<#Z_*6VxaI8m_nP z-&=F*clEE{?Em*(|Ne*bS2;s|KPWF8ON_2Q}jFE6hPdi(pA`MdM=AshDp z|M~yqLv{|M*(^Ci9v2c0HgfW7xxCn*=+Y)69#ms-G3jWBq`uRgpa2FBBL&tD9?MNC zCnsz9Zt}4#T6%hhM*5*8H!m$aGs`Of-I5CHOE6W0B z2Gwc>I5{<@Y|A)%$~61n`q=G#ysJakM66Fe*_3K+_V(u1g0tJ^z7Erk2vRYBDSBi2 z`+NJFnAK(8m>WF$f4IwX;-f7)J|-V;IB75~r^4vj=~`{=b8~JKr=Okg*~?}7Ys-+uL@$t!t>Du}Keye_YacOz* z`nT~w(P4Vyd+5Y+d>G9?H`Sa~G&;Mk9?|$e1 zhx!KA71tY>L@E~4aei99pq_{6#exO_p%dYWY-$>pnz^%n+^m(o_I+WyVw1(9Fi(%t zq|o5VsY+eiyH-33@f0;x4siQfvbfjsQpe*yt7|uuC)i)>5b4nsJE=5DUuvgHsFKxA zsm?$<%ha0q&SkfzE|Bby%7{(-sXG0CTW)9SjMPa#XUvRh>Xe?E>-AG@cEPsow<}AR zl`o%L`Dmw1MeQfCyABt7jV>)v5Y;-cpqZ^pV{y0Ku8i_dBdb#qQ`Dj~mraj~(p)sN zXxGZ6bDOMI&cFS#^3}W*tGbrYUVG}L)|wwncdDiNKMTLMwsPb2?C?Ed9ShdK?=`%> zAyt(7+QwK_>+7i!RhF;gHhTTm+NwBf#*>NKYOYh=_ZqHL*~XuH`oa#*lNu`hPIWEo z0vwmUzP#(hB*&L(Gp>2B-~02??)MVkzpZ}1|Ie@2`I(MRvy>;^PusoWkjAvz1}+hI z*;40-u5X(b!(*8#etg5tZyS$psf$!xm-$W2XiIv~3AJOr;+y|$I&HW+H8m+YZCc>8 z^2jyDM{PgkGe>ln3^I(LczLKg)D~boH&R z*M1ps313|~>#oVI^y+Bc+u5$S&F&~M-G0!<|1B$Vov>cT_dAl8{%+4YCGgH8hj6N3RG6C(r1e+KZGbPEnP za|mn2oIp8~PPkF#O2@{<$NLqWyJS2!Ejc+^BY0KJ$xX;-(peP0x^r^#^7Hc@;AhfJ z)`~r~W##4N0gJundTm{Gbv5)%y6fu`4tL3VZ(DP7bH?RWv8T7Ky}iBQ@u|7q+t=OQ zUGe$V-P7CG-{0TB%q{1$W5dJ49m3jiXLf9Se0+kk_dK7So1UJYVVr&M%+Afv&o6N9 zmh;`U<>loS!K>rW?%MkL`iA7w^L%%2dwY9F@#}kMcW-}xUxkr_MZt#Q;L`#$9m&_!yi!&g+}&G;nS*k%w9Zb7I4#e*dh{^@vv1QZNaIh2*dlku9wvLLP}J z6WygemrU}|oB3q2pWDqRQ+&cWRi}nWX)c`>kvH?{^tiU0PiMqT<5Zg&U$*tAL$=mS zwT^tLnJ#k*j#)0BTXOH`vUz!*RxX)e^h{G@Udubn6$@LxRjydn`ETcn#U<-jrZHsm zMLt>TowO=**_^yrnadZ{X=SZg(wCLBa>cw=S*zBpd!@B>L)L%QmlH~_t$Mz8!mU}_ z>l(kUTD^YHKdqb%N8V-WY&gz0J7?nwv(-79qeEs*U9ur6YuSwJi#p%Vyjgbo?e^Pk ztKaUp*Y#T0X-URWjYV(Pz0O8IMuaV z*KJ!>aEM3$O~GLSd!51~LLsk3*PgLkU3g4J|MkYrvdw=!9#d}j*>obvMS9xS2a|3W z?bMt7`_mbt>oT9u8c1tC37fP{$U%F_51uU-9RJIFx#*&vyXBIz{@aqv0phZzmxIE6 zzh3o^o@;t7IR9-7=|6J8S#iZMSnb&;52M=kQ;%yXA*ENqb6gyACJ4t^>&`WbV}>OtkUgC-gjqjzPt0;oc4D+ zUo7a)t9rSjex23J)${LFy;^hp-L5wq&gWIX-LZe2_1pE)e?sq7v*%lUILxeP^YMgq z{GX4f)X)Fv+#-GV@y9R_fDYD=Fh^%|7%tpR${x@;o$Q~pj+Kx z%^58Q(H;fXK5eu+{`7$%PW;7H)!*KK7*fD0A9&tXq5FLvKOP zVwvYN>?GqpbbpLqEO&Z`yUDk}-Wr~iuX{M`_0m30X!Cg@Ccg5ho6@9-Q-r$}7+)Uu z{P%Ivv@=US`Eb~a9Q#;2lUY^P-Sbq?wDWz_CW{C%em7RJ>Z@EG^5{v@PM7d=K^@z- zJmoF*JiSFwsDHz%2iIS{3+58IWOj92&huaDlbI#JyE)Z`|U zrF-_QOv$ahe(ILVrWs2!^-Q%dpN*TgIoVaq|M9PDmtKC|lG~b{>A5<}y{$~E{_o@1 z&%46+|Gy=+A@OcP$i_N7r1j7nQWhNF}t;RxwOfn zTK)X)UYeCJMHbMpL?DhLHU;S)`mv@y2qk~g~%7wei&gHrD^5&O`>O9${8?oL_FMs9t zWm%lzKdu(O{wyJ3``WZ8>00x;uWK`HxuPFeUElYtbY1J-*NObqJb?lpEc=quzOB1n zeRi#W$?fIOzKR*&%6oQi^X~0$#n-RDIDyZ6$MM?C!s)Z`O!eEoWBS>5!dy!(Tg%B! z%$@T}d43i5Rn?*v-t+{f*Kbd3=*#V?-upW8^{od7^ePSs))lm}OFul=_hap3*=cG2 zCC~@aFyN_|#t&6IZd*UBHd)p1x9j(*r_Bt5HJlv7idTOa&#;X3tS2+i)wCC_; zteWijYRwN*>3OriZkn9L%O1HcOsDVd)5YO*x|_D; z|G9a|SaP|k%(Qo-fG5vyV7Luy&n?nO>V^cf0J-$WD?!}c~<-Tk5w~Q<{5gXZ*kUZ1%iw|C<~4yx+2Q*1?5p zdgt$7%dd8xvEFBS!R=YmZ+`vX_3$uX**|f=55I+P|39%ThuN?)LXq_jbGk}e%(7#J zI?u{V%EKK$N6WM&6&^2t@wwvpw8pySjnB3<%2{N*b!&(UjBYWmyBXHxpq5g8l$CQ? ze#O&d*0iQ$>h&A1awQ)x_1|61w!Ht2@?Yju*M5mdB)p|wry+Gx!%fRo{?~()r7q+kui=@yuP+IHELRF-i@|` z6KzEnsreS|$Chv!9|@k9(pdAuZR-Tfh;QvB8Xb8a9l04DjT{|0$sLAn0<-@gFitdy zoYPu0GquXQtaI+v&M6k1lXjRFw+1Y3ZB@G-wK$^d53A>+r6Qh7+t$ozTj$ZdYic{o z#r7=`-K8ywEmPeyIJzIR)MwrZ-E+fiMq`w z*gkCUoVKEK?hA{o6_Ebz0iZh_IESQ3(QXE^y_8uL&(Y$yF3Nz1(wp z)y(NNJ3*(>HT<03BsrtS5_BeAN9ByJnKMcw3+)zFOyI1Xcr$%+XVDbRnKLS9&dQuQ zCvxVznKS2d&YHh-=3>cNOEhP#sGPMbbJm*3S(7cf6qE$)4~a%O@O8U!ZS&07d6H}Q z%-K6CXYZ4obHH*=m)e}8o^$qB&N(%6&Y8?PXFKPdlbqXqZ1#o7xu-SfUeBC+e&*ae zGv{2{IVVn#b;5rKfoWy6Pb=p=n>p{r&UvqH&U^E7-aEs*eq(XMb%;}uK62xE@C_| zKQeH(%&bLwXNKLcTypH^To^4_Wy{i{|moZ_3(wW4>{icYPSdv30re2TAm z)=C+vl?%NV%(}HAVIk{80ft!z(mTJeTIbceE^5W*SIakMt)9?jvg20z_EW2OzFHl^ zv1Zq;)f;xLo};mbp#0{*|td>nV zx$=J2^eL9>7Q9M-thMf`)Veoa>-MZ#_d;sv+gB@}aIJfnwf^g@^>cQucgtG$dDr@< zTI+wDTG2Uc-RG)xU%1x)+O_W6Ev^q^@Ly|#zxLJ#RVxC$=h}S|opo$k$*--Y zT-%bQw`u&ImpXf6ns+AS1eU~wtp9#4^?NlpY}K5osO`nt+as&C7w+Dk(Y3u~_4eA+ z+Y6+3v{>(G%ig}-Q!IJ+j>OwLdVcTdrH(@0@>o z=YrQemssywroC&0^sZIWyAl|eFf#1iAiZ;=_3llx=LHoMCphw*yPCfHcg&vG%lAs} zIbgkKfApSx)q4(Q?>X7M=g8?jr*7{#Exq@G_1^Q*d(Tzxy_CK8CijBPD@BtV+AIG5 zFphtg`owzQv*>*-*#YjH zoimH~cUw&LpE-f8=RmhaYRbZ?|4;A#SM3=)v-^(6WHFz~FFCp<9PRwn(fPxoUv^Eu z;-7C8Fe@n;He&S5{DBPM(7ZTAF=?l~OxCReh@U3yNxiq0XKKSyKs^sCw& zN|NzRd3N~!jw9}Sj_KAMarF@~k~!*cbEHTn;{D{q;d72h*c@H7b$@`(@lv1TX*Fg! zYmT(8IhMP|Hu=Bci5{L4#b=HO1V%hJj+yetcIutz89Fi3_MDt46En}pYCccQ0-IBd zVov36cZ!_T9sS4a&gIF69(S*OW4n&0>J;3 zJZtTFyXM1lznrU)IA2|3wz{Wt!;&Cj-(!k@4jicHPhI2q%SPmc&E&^(>`tDsREWKF zvf@$zXRem7-Sjmlc}*wty*=;idCKtrmNT-vB3C8_vF*LgAsfBBRFp^dN?7V-sWqYO zwHIyQo;dKs-kkUNAD#G>{#Zce)GQkSom_ml}q=X zJJu!GrJOa(ynFJIaZK)Ar2v&lsppWtL4|7m(hMAB-~OcLE@`Z1?5#F^~T+V`Ei zg6ZCa=wqw5FHd!4u4sK;p_5{k-gB%@#&%zB{pts{53cy#44HWSi}C#{zKZMoA1s+9 z+4<=06|Qd<-HZ$Y55H91IeT~a^v9{}X%_n)+x<(qwdAp-+`aX)eec>nxXJQhz4g6X zSqJUUSJp76z5bg%aW7ly%3EQ{6W)m2k4@%gIr%ie!$d>H^w$x;zrH5S_cFFVNdNmc z{WMov@3u9I1s;6Qeqi(OSxwr08^4(e$DY;XJ?{-aaJ%QW%*xvmD)wprld~Ba?wtL5 z`j3jqp_qg6KOf~J_iudDc`5njBj&W6gw$zqcZ09-+23Tl@ON?)+p7fG_FHRhZ`igw z$lbfP*4;nusNXfNMFuw~+TM(Oo||2JO5689=(nrUY8|1=UZrZ@ikbE*knPQ(d2ikr zU$+juZhMn&4|~N1or@x|$J*ZpNnJU5Z{1t9IX6P~+?d0Bo7*QfKKI&E)yp&2On6#n zm9p|3|H)fBCf^cNeHStDYIyCHkKy<7j@T8-UOHQ2w_xt2(v(x$BIgxyKP-%~TPpkE zweyvzoGV!+cf?*gUeBA@n-{x9$HTDoW3!8`yuXug@8v0%CR~!4X8e*XUe5IHl}|Ti zo`wGBF!|cM#^Lc(GyVIP`JWT^eYVQ?F?#>`8c(aP?~7AsK5X-~Y~$++_w!0fHyl=+;rELDwYdrUZnPUwizgO7@U0C;UqQ|j*zt`d?Co1^8v|9PY_}>ee{+GYD z+zx~4yX0>?m}jo4FY?6iD$kQMVJv6cu6&sJw`}&?OToO3bI%JGo_YUU?l%kT zcc<9jNx4DMZ~LX!d3y8y_WXN%ZT_dl_unz@{S~nHmv4-HW$*Qk>)!AG|0m!0^|RvF zQ)2(_=Cga(*T=E{!>#}JA6~oIZSp;-9dBNE_u7xYwtMRu;sY)1rdaN)eZAf8PVv1b zM{8bw@HjK|-D&S*w)5ZlG6*iXdZ3IsT2#csV4-6h*G-m$!>bYxcL?ih@h}8FI@-y~ zEo37hl+vIp={wJ5X3$fwX}YodY9c44FhsLu^X<8LN>n>6=~{>6=B{43}yQd?h!t&Lq6b@XKEs^AT&OTAP>zvcvW3Nc?%I@*=1-5DKe zxVL+XdgR{Ht9Xrrhdb6W`_c*YeAyYj6K=m{rodH8itl)xxT8Z=)<$cQ9%>%andkba__Z zDSSO*qik`6 zcA<9@Hb?E{wO@Yak-z=lGrRoPg-uG{V7qfpa)BeKbkFJ)iy!UM+k9Vc&TE^tbxiAR z1l@J`RfH9}6L`Lc1sZDfiB;)pwV4U$DM|daIZNIa)|`N}$ukEym|Pb}rcxG;Npl z$K5^CPJcP&XTMb_#K&2+G$hzoSM+MIYv|U{uy|k5#X7o6x9&N1t~gc6<-bd?dUH|l z<#7IX)oTlax>UtHQ>24l-$GP~Bc>Fv7cg4oua8^2*pK)Y`)?Hwdv#}N?y2?nJNbkm?CZAO&*xQj#ArqS^YiR= zN)mehtYBV|W$MjK6d-|GIDX-E|r-R@}#UE@toqbWM6%G zouztn@oRe|GJvf!IiJD0c`e-rGTm;I3E|BAzo^Df4=%5;i- zU7>$Dv9L@2w#UIX4bPW`7bi{+xuLvt`blrTi<82xELkJQX=*s_(uCz-7HbA~dbw0- zH!NNv%J|m8JL=k{Y1{t@=tySHkjXmT_FXAuPs7X83HP$wkISeTweIvuR=Yg&%$ub~ zvrqb5e7PyeVLwZ%`uZOywbL%ozRQxXu~qY2KHKV9*RBMKN3J{-w(0WR*DcSrF0T+0 zTomlG<4C&Q*PZ7RA6}gI(xk`cw&eLnt}k;}^Q62G`FUQ~bkm%WC(lHbt^B&|t}J@E zM#HWB#Dy%`EeqtoW_WOFU0OC*rC%ntN9VJpdEK@vOH0?Nd!1Z-G{dfVmigMuT{*LY z=lm0zW%oAIu+l1|dgJRkMq9(&WL5<#7v;?M?~MzQwJ==eHFat5)~w*wQGuO-?^k&E z`XJ9~RWn#KJY_hExMvAzPOFhqPN_#@KTH_mS1)18&_ zJakTip&En1Hl3TB(=IQHUA;~B_V&ETldf{A92C=x6yCz*#HD&uH)UJTUg?QE(LuGd zw@tdVC5Pci@=2BnPFdX#))W|@zPo0par$PVNd|wk0=yeCh?`+l9*Vi`0pYHRG z-umQ<^+|=&Ss_L44|A8_6qvnmp=PW#e2D#vl82X1a&mN|hK+4~3&*%RL2eiG?wKOJNdp zvDs#z{X_bN<4P~#MFQ3=O^=d_hkmale z+51*z&0e$aRaR8QzW-X;tG5_gie|Ds^jf}R&!kt`>ks_%QYse}Y+Jc>rqb#+8_&eO z%Gq?*>GzvOJFJeZ+;A|h`|Y+{Oj%jZwuWY}cWj;J{ch*8WxH3!7I|&a-u|IX`u(0y z>&ycD!#!OC7C!Qme!J-(o6g$<48nijADkR^`@+_*mtGef7O4NV*6D}evUf*cr1g9} zCbd6vxsF*u*Ixn9h$=hHdO&o!TpaJQcR zctKV;x8&mHP0~e9D!=c1zU-H;U9_*|-R<>fBCgLdJsWd>&DLw)+DnYDq`1d!+wRJx zl`6DstFFxLg4trTZWkTr{eGwH@!M_p%5VRVHNTtBeD3@G%HOgT51XXzemtz!epm5m zg0f!axFcG+i$nC z^Y?zgQ-A;Ok4Mwx|NVTh+~4lk?d`(boHif7zw6JJ^ZE6Ef81YR|L+fYPV3Kn2G8rV zjV#O5Ut^i}1f9)^mE zJqa?4<=*c&ZUee!sYXsX*uvdXY7^+xAsKFo2IeY*76q9^6>knt`JfMz`lqC*nx6FZ zD%;dF`^ypy$Db#Kqg*C-`lxD!uk`wVvp^$MSW@Am$7#E3MboyOQPrDj;bF3E($oWg zmKyL^9$lSsvGE(n6T``syP~y%r=P!*YBHT;ld+h}jG#9%?>AVU$&1?9;ANsr*G ztLSR`*K$G`3qvKBsmF7cM;92jJen+^vs^by&?~4 zMf<<=OMmrb+NM{Xui;8rW;FGs^l$NiCcZDrwQH8{b5ZkF%z9VB-TTsavggH_edm^b zz7iv0dW)}QQ}L`ZMFyF&N<*ScJ5;ld$s zMTCL#NZy8?zq!ed>UWMRP1|_HbzSnwQ+F<~I&iaGba7ttz-q}Z(XIKW%i}gzM=r_w zzU9u|*GccY?!JE|x>Qj$)BS39VA{V$a@!j6pFfd~zjg8ZzHevW*L;n>|M%DS{nD&b z&Px>T{Q4VQvBUaJLH+j2PnCrB39QR+QP+9M75w9n*tP;)wHWU97g>wEbZNwF+xEk${`#WemRGUgXQxTKPhMG3>3iD8 z%$)!K`nM0{GE;4C8@lZ%Ha%wC`+d!GJNBFBEB|d?!0el`)r)~~xspdlyZZh|MiFJ- zs^_Nizj+`TR(Uw#+nwp=YhU^mN7l{hn?FO>DYkD?MdkI<;;z@fqC^+ZTBmFFHtS`~ zZM{zs=k3pJ)mM1K&+)$M{MR2}YwCFut68jWELPs8FJ?S}!{*5LrO}&;md=gHw6-jt zGVPPd$~me2xBD+IlzXt^IZK@B^Q&9ST(@_}X2+IUzdv%Hcg4)|6LDXrZMmoX_hWtk zTke?S({KEF`fcA&=@(%VtqSMl3KEx}-Szk$-z8qZyGi-!k-7FCFNq&BX*WJs$XhIZ z=wqVeBNJ13Pl`SvBNnge`A>r(wbD_)N|VKKk5m-&mpJ*TY?ec7rS z7r))#$$s|{|G6*6r=I(=wAqG1K*HmSY|*n|d;Yn?{2!m++H1*q==PDbB|C4F&Nx%Y zKkK}sS^C1O`_i|)zWFxOzS@$%cEY{`>n~L-k3afZ@7i-N3B_Zpw?2rs`*u*=uIK9u z&vfBj|9#4~tNM>y2MYJTKehbIr}PQTg&|1M~0iKj&I-hJ;ii|;P0PT9tr>d@!h2E)5Q1w&$Rdd zJl${iW%>U<&reVOb?*GWFRRzrzH#4QevkkE|KQ8+zn;vzXSBTJ@8f#=n`aiSd-S2a z{H;;|95@c>K{w$m^f-`!*!S%D;Kg?y;iN_{9$`HHHhm)CGU;~ z!3vvYEmh1j8d)TKHk7;ln_j(Hyh*{Mg5gGm^omA}9Ug2qD&=R?@3>N=E72UEBKhKY z(7uu;iy2KFA?^kmO?oeyjVzknSQ;i7uvu2LSZTOwe~wwRS?&w^)qH~@{*UF5p)g4`nZgeeN(Y4;9ds9UB8jtSn8Qrs9 z1b7q(hKpInyICE4k$dQd)!`NGM=h+6TeP3#=sEVI=M+cpIf>pY7QNSQ^xW|1xizC* z@wh_nPkMYwazL)-MF7RX=Y2vk&d7b9T%4hI9<%noZ1x}Q8rJ4K~`+?R)?wCTc>@Nj<4DbAe^+tmK)(WwR#CoMm@&=8TtZSvv)`T29*?%sq4R)M!bC;FXqZ z4{-exnd2=vXWil{iO;6~E1Al|IZb@&bPtKS1}cJbnyiW|CtKc}{;72OjY_}Is(s6D zOxOCa*{1b#?(`Rv54KIdYdPh`%elrk=e@O@wejTSWtKB-GUu?@&v=(_-EpcD9#PiqWfCxcLsinqQi-Wrshpt+zm9>=DYiabVrIB4r6|OH9 z;_7=av+s#VTgIyR$ra18W-a@-by=F!^0z0J<-b~1QnkD&YGLuLeg`o)HW$|)__g}XtJUXj*{c31K4!J{_^Q=B9*x2pjW>3!y>)Btw_|JXzgnwQ z+9>3>_F2^0V8=C0msY>owdVY*br-AFeaf14==!>|S?k}g@;m9$JiWO2#IN~&VRH(5t-eVM!|VE0!0rJKTjZ?&l28pX2N zl%wLG)ZA6uV={l&eR6M*ldg`C-k!w0EvkD{+3RiE65&o$ws@}Ip7GUoQK;RIXCAgU z9CgcVbf@oF7{24ttu4F+bo^yJsiwPDr=faoXq7t6f`f?>ex0x9Qj2hqU+X z<=%5Vde6q!ezT=_o}2B(^LvAr_r}KCj{G}TyX@ZLHhb^o?!CI-8_NF|3ZA##cY#~a zGG*tY+50}M5`FKo@13`MrSz_YueQHjykkc9KAV*N@1EK`l&&`J-o+qO6O_I0RrP+h zH)~3N@8hzW>v(!^&FcdKd-e+H?B$mUQopiZY4ZUV)=19Z+otSZ_oMm%YtMnR(g)Pe zG)gEQQk>)67gc>DI{o)AT^|;Qy#i@p0}eB-DnEDn;EKbC=RZFDj{V58!-t*LH2GF& zIi_$g-oTc$fo;(Sw#5r(>+U%eVSQAq$4>X@mfw%L*DPRLw16#h0o$qtY^x7A-Bfc( ze`Ebi#3A#K<)u}YyDaVZF*p>@Ic)kRJ2S+Gy~6S2=eRWjY*`E^RvB<78nAa;`hTg8 z-1pylf5)Ev(?etVOrw7P=KnOsf9lcQc{6P9M+cvEt}4z6T^-54x9#}*tJzD`Lqosu ze^n6JZFanP&#c((r*6GGHPv;(>KOi7%j02pc4#l%`0w)3WF4+88@Q7GoLa3MTsA#~ zd#XRT=t*-^0q&&%-*X`f=L8m<;mUE~-l}kJ%K@(1lU&cw zux)kV{(6Azr4Rep2j@P-aDNFnf6R^Rsm-|;HRqoDu-ERKEgg35xzB}Hd$?Hka&2)q zx8}pC`zjiW%$w}DoJus{&N5(My@9P&CTC8u4S#8{=vMy=Z_XN~2K%+1m5UAL5e*F5 zdd%}^!0kU5f2RxV`u{Uv|ApyW1Gu?*FRZ@6UF&&{=k4WpHRtSN&+Titz;id7(crwL zFZbRB7aZ;;?q7C}+xGmeofqESIp=(qYj44cty2yZ@tom)d-Nzz<*7CP&rB}KpS8@s z8*uhbRQ}Xpg}eT^K_;c&U+hXYriQ^!NId*mKitXEd&Ln}7S{spiuSe~u&xoM?M}9rH7?! z^H@SJPfxqye7(OX>JdlqjIpJ>#d9)_A)VnasOo)78$T@DLAxh%eK$gGh(d|c<8uXSbH_% z?$zBkhd$-*OjvvH$=?gfZJyU-9fNZ!+gG1#(BARY`yg}P4#skG^?nLA4-MPzb7==!*yjyyeIqrh-EY)Wx zdVKaw-*Zwe%}>?t5rbTkneV2ryv~Ym1D;(GSM&{BYaV#*&*KMY&iFnJ=xCiF_VkJR zyz3pTwWoXhd5%8e*&6mh=gF;@r*l3}im0ni$UJ-LO+@$-(HD|InT-?_Ql&wPJk+52TWqUImi|<`K z^Cv{P_8O1s^Yc2F402zYq&|D_#b_*mw%GaKF%(`VGJo|y#*ZaS`?K-L- zxbEIQ+wQ%%^g-@CCxL%E0z!^_m;ErC^`iNcjg93;KXrf5z47?t=4{iL!xcIogyygL zxZ2t444bzNm&E$nT>eLsejiHKUX%KJV|upzfjMl6Hpf;yxZvvU{J78l<8rr~|F>ly z?zJyGV_!Pw@{upE-#otAsB^5vhJDeUi`%npHTWJ#i$1y-9Q$nZv3K)amznNf+B-p) z{q|hdMT>-Rfr&ZzTUy1wR;i`=D^%x4wPUaBp;erC_?;+PwCwKuun{&@B0+~oJ? zpZ659)SiF&=6qZ40+ITZ8DCb_+pvqzy(svT`S}|D{#U-X<-j-X_#cITFJ0()t@ZcuDI5Q=c{R&<&zA4KzBli8>Dx1}^1rX? z3pV*$JSmsE-uEX%;A*+qRokMjCW$5_*oHA@nx;6f4r_?|mUB}$;&nm;gM{WaXN!#w zb-Pvd=b2H3iJ}HH52tHq=a{EdO!(9LR#witB zra8oKtr7jQ+WfKBzG`l{vyv6>@9*o$zRC1*`}1?NC+nW?nHl-nYwO%x)(B;<-i*NP zjFYZ!BSEJQd9zjAirV)4#=8IM_x&OTzvta9Hvd2EdF{0{dH+A#>XJEL2XB84|9S7r$LE)m?f2JOfBAfO>+Rc<8seJoel3HYf4v`U$rjPpK`Puxt6eKC3T&r%#>PeB$(+2+oy8OStB&@;XxB#lP~4 z)2d5yOGLc%8?4?h(s1DW-%@;7ojrGbz%L$A{{Oplmo!ZDRGpo~;TtCPOUENjZk4K~ zXZAw&g>7<+mddYs5cSe{$D4&m6@Bz&J3_4@6g&FbFWB(;PKapqQD_RX-RDr_i);7`QqZgobTcl-}qP? zgX`S=C07>b>@B;M-=6>NPVoK{WwXUv4HckQA}})OR5LI!m@*RDa3e8s2yeJ4WQ-Bv z+q2_i^ATnh@qIN4#ZNCNs)^tDvm=1v#?18aeYU&5zdzlX{QtRMflc{`t@Bs6@7rhl z`}@1chv)0d@A>=j{`tr0_4516|Ngw{e!qc9%;Q00daj~lJ=%sFelNufVkxQ%@3ud0 zy)n05p)W$IQ)y8~QWs-Vg?t0gCXL5kCaW?YcUmlyXb_QMyO12A$ENvYf{R$@lL;A> z6$=`D^j@aaxKGkp+NouinL0T_&r&wRR!idP^n^CeXEVNKT7>m`U7N9ZmhrtC&t~Oq z(oCOIawzlp?22=hkNWKUT%XNvVAFchoR&G`+0>4-l^KhAR&Bq(Xo8v6%Ei`Au56BR ze5+nAo70q;Ha}r$Wcs|tuRLGPUA8Rh)v6Vnsw?i`Q=vnwhfXil0@2w#_7^gKE(p7rhwP=4@`oAGh;7wu!qUWqn4rU!htyWg(-?f2|Mqd)|4U zQe%vm({kLkdbR_D!xd{i2F?ZEZcO{X&Cq*jckOr1$#!RPlLHI>>&l#FkzVt?)xqH1 z{Gyf29EJCo8hH}BDwC&|zg?dEBeUHw{jK%21K;KLTstJbUb%l$*VNyi&SYEVzPP}w zSNr9PKfh`utNHrB-x4RCTYCD~f4P4j9tr2y{d}>$uO{-&_V@cH)KvfY_vedy#LIA= z{EVu)xal0<$Irf49EO?tkLYJ4OQ?rksU5hp!xDE=p}RKJ)O@IU#4+UjlXM zw=V4a?{aKyr)Xf3i_ap#sU1gDtu}TB*env6X>n9N%&;ru%p%dP9Y=MmHg+fMStPO3 z;#jArQxo&geJ)}JWDk|8*XNOnzF)YsaE*O zQ(^y_@>`xL%iXm&Ze12MY4ee#dS5fV{oh^c-#O)}elzEpM5*8zCv?(`I#2qy~EeE?6drR9(B{>nZ89ym*?DaQ8&N((zmSX^4v#Xo^l`E zbS6&g^SrZbmg_iI{`aZ*W<3Ajm2?Mot_w~7iWFj{ytTzoU1+oVvQVH`LpwfF%vf{t zwBJ1%Zv35r3HK&V+ryLQ?szJ2;;b!8G#b~e+h za98l$b(5Fdf6cURv@@C9Mow$~MJ&YCG#@N4Ec8H+6}@(OjSKRk!cmD_gQMRy8BGZSNAjud_~+ z>ex*0zH(e^+D@Nby%NdYcQ!j6(B8niMB{x1e|62KZ7W#T=hW`J`Ke57)xprEwo4is zHr8-W6=6+hSoYKF$*tyn_vXH@*(-hZ%dhYI1;4sR8F-&A+w)0&L2kRQP|gXM6-phD@m8R zD-}HbnEAGiJv%KExaRLTtUu3S$G?kx5BXB!)cqb;*n2(TuKspX%1t%nc64AESIR8) zdup|gGEeoYe@MY3n2H z<~+80f8%^d>AD5We?NC(pLL<#PCM%OpV(eAkyCzV`Sl;)octP8sbjl%%QEx7_YL?} zm|`;T23LEBhTd!5rs%7>#84*F?eWaRZhQ_b>$0c5Ii?jB!&b76ckgTG$1|=s|J%C0 zv?jt+Ci~(Vzi+Fi>t?2HkGfgIlAHb9Gtw{m_pO}JEs-fw&-1STJp16;x9vx5pS!is z-d=3Cea_o6d8Nlg<|$wCa_YSMx{7`Fz3%dUP4w@A6zNYw>A_` z;=cX#M_PKy=}T{Bao>6N@{&MCZe78={LsmqsTRk*cN=KFcoprezxs$}=ex4Ww_o~} zf8TNO?AqwuLt?@4+pk>y`(>?j?XnCR%Tw`rMKg@Iz1TG=?c{a8vTgr&Kj8Sj^UmLn z&7B8p-`VcpsVXivZHaq~g4#Fh$m#b)yV8Z;7heCp^%m%F!;-5A&`R{$T^X7rfWK}NKn=MsmeX338{rtAoz2IG8 z{_nfL_kBNbz4k+;{vXif#D4R-PZRC`Jbi9AMdsa;g2eupadPG>PU>cT>+SygF8luP zJOB6n|8dy=-`m;pf1bh^}5>6J6SH%xxs&>R}b;NBqiBXvP)J=4d!SjOt#<&6pwsUk-k z84KKE7^^plH);KdcXMlsDRc`oU=yk+669z!sc4?m-ppf>%yC0hb4QayN7Uh~&7ZzC zI##sY6l2i4QSNc0C51Ig=Aya5lCtUH&0#B=c{7?VceLK!(h}WR&v-!4&!R1HYGcrf zmY?Y@DLdMDo@T}~ax$H0D>~uD7NIe7TJr7beo=0|-%1jeZD}?>(HbeycvC$)*)Z~< zss6Gne9M-^ZvC$kSNx*jl6X|>V$q+$jaf(C7!#5jrhD>Eb_;hCnByU^_Cb>Bw~h%C zoy%P-l%_keUF8x~6ZmY@&Dc=-Um;0Qtb0R5w_{lMoQ{;OE0PXYboYh#EO=f~=F$>o znBT41rF$(Y>x=H*8{HqbcAoDLsAcJhTG1P>(ebGydyYszW(qggvZSXP?oTwj8_e~; zDfH-yC1$hqbgf7^_M`HfM%77{k}KCl!@@kLPVHLtB6ZD;^1$n*N=rTBrcYptoWQfP zl>6lb-j})6YTAt_dLtrQ#Um#+S4`wsXc^vUX3p#;Z@|;+z_jJ~dc!YEuus+<`;bL#%D`AOG%8)r^l6gh2)W^#Jx^v=#%ttY4DNlxk8Ib}j+ zmHy0G?K@{}J6T-yQgpgS(Za}CDV(z!BsUctIzS%Z+=FNpucg~Evo^d~P9;?=ZSCxxYrDlA7GV$uM`6pg3GT_R* z_+^@)ROfz&g`tTR8^0`?(BXaAa^7FbC6BKzR=G8s<A~a{a!2QzXDB8%$ybB+qpQ*aeBb5RmH4J%)h5qb_#5p zHh)LhvK?K^(z=!yT~8KsPxO^qbB1NLzg0lXsg@9{_OMk`RF`HxV4a|JYVIYeunSh1 zVc!-C|D5W+Yi(xNqCKzH+Plu#KWlDs=vp4Dsv35&lUa+4D=RWn*G*wt|3s^PChLNq z?yakiuHC(AebUV-8K;&!-W9tpY7L9_;+jRPLN~5E^=jVPQ{ET9^ZzejC{>ky?N-UN zw1wig-2|&Q+I6kXF3DOsE&BblXt}g`i=OcBd%&;tn>*{q+H|gU?621A%$&#Y+xzSP z0PZyno3{pVJHINN%DRS8dwuVZ42S6cvKrXDce5uNuuE%iEllCxCgGK{+O6wGZ1$GIBkVPs zCAKfvk`VNb|0}~rsn^@pr5Wm@z5WR#YF2w{Wpn=v;4a>>@mf@teEH=2OMKT%U|YFi z%N7IfOa*R%-`siJe)me_t=V_AaPNvZ?v{7E@Be}A3y#J==}7(cHF=@6euUeu723P@ zEl4Oe(GQ=U%H+6f+i(7T1)Ki{?AdyOe{TT)(c9dAC+yk#fd6#&o>SU;w?5!Le|nF` zX|8kJd(W)i^Y#CRJp!+}*EH-sTfOI4H23O;J*yqKUt}*xK2?#evMp1A{i`>3)&#b! z-)jmyL5BTp?Z{FgtZ|{Xecf7eD-QMxnf!nC&u>73E_0>C! zYBrni-mLRxpK8uwb)KCsx;AiTdrjBoHnU;(*5(f7c4s@*x%@__ziDFMZ{2j)luIT@ zWA5->itt>zqf<3(&nyXs|Id3E-W>fO!2K}$ux!lXOVaz!MDsh{IsDF>`(gCq>KN|- z*SYpxI8^P!{pG@*huxd<{~YT2e`b$S%n99?6J>Y!`JQeS%?gsT-d1FAC?$HopmqPw z6WyD4bbt8Kt(MmJL?fZ>$nIAj-Sc^R@>O~x+`FrfbnMpHwy-Bf;mqFi(fhVM;4i(i z$3W+Zq0c^R>%9zjc3fJ$Nny|NHXQ-^J$uaV>@}LR_w?)IjeF|3D!o-sMXX)Gw(7u^ zl@GSO+MfJ6{M?K3bB}wDT{l0+9<}Gq9lp@)%15ib|EqKR>9DURfY zznmB7KE^!P=l}P{Ii*_!ezmtQ-B9O#GH~Hy7SSDv-fK!(HdVD>tW@7$UF9z&d*PVI z>XNCm8E36F*u7rw_u5(C7wGOX(?2^5<>rQKe;GJEoyA!z8c0Y-@`a9HjtHz#x4ZApWuSTuy zSkzs*pn7-swq227+hw=yS|Ht@m9~2;_wFFyt9jDN|5z`EC7!hX+qc2upy#(;9>+Q| zY_G0gec+&Ue=Bbk|Fl!#wJFhi-Ppqp)aSaVeoNZUaZ~ME_wE&ao^A)VuI+MdJIK2x zDX-gj_3nfk?y%_XXH7W{JW#s1%;I3^p989HfjPcOhG9toXLoPbh_9^f(n!0u)ca`O zl--YK+-RGd(z7By-sZrz_FKzqy9L7dH%j06b1URyciioxyKlMmc>e!;L%;3T?^J=r zKi6xjZ|wQedF01Umu1zB>Am?+PCk3#)>NDD-L&gs#*Gg@Ztk?W%2jrMd&jK?o2yK_ zy_&t|pUBE>NPqC}MJi*#)&*Y*m~O7KUUmIu=0<;WPloV^={8qNmpy!Bw(0klyl31r7vymyKeb%YL(1Oy~up~{GngVf}&O|Y1w4Qw}WqPRmI#5FWl~~ zeQ=OLW~ZQBocF)Q;@>X{aX)2Lcv>N{F;Z(;+|QYfVf-^)-+{CbT;p*z*C)?OO`g~)r zE?J$FdiF}vuRHs6ciEd=PmsM{q<#I;lvi&n(*F1ES{!|~_Ahsvoj{$(zN5EKAIsTy z_4U4^vo~F~K7K=bufXeLXJ5ammf3sy+}1@VPy7@wGPv$9YG5mJ;La9cD{8o~*xH>t zjJtGiXZT&N1AVu)_@wOAxXIi6lu76Ao4eO9*e3n0y?W2}^npD7=N<>{eYt<3_QUGa z@8|WVoLF;6Y0Y7|Ihzf74(&5I^i=z!#-B#rJ)7^G-mCiNnEsn%!Kv%-A5MMpdP|7| zckzd_^KM_wS#o>k*`uHTPdzy22A7_G@Aer7-OY~1`rK;@Iki&f^Ggp84xNS;mp~i) zqg&5*=(Uw(i&UIGcU<%Ro{Kz(zAQMtcg^t@`1qx;OiTe$L?= zIstBHA1}PZ_p^JaxAwUk`@E*U_7mLK8SCxaG_P}E$ZsoAhPpXk=R&=1*@@m+wrN$u z{xj>j_9Q&r(=vhMetRr#>jq8Go_?st^3$_3likbhVt*CCzO+#P`aId+rLXSnPyWvz zU%UJJr@IH|tDmo{t@`rlHl(MOHJZR!FJMo~I z&yC|@i;&rh`3~RjvQMjf=KB0$yF#1BqYjm68IL+OmaTZyrL(OiFtb>xOKowp&@Z96m3FsY%x{p3TDi2w%PMox1gljmX3jQ?TD5Ru)vuRxm;c+j za?bik|24Chtvc88e0Ki5U25wp=k0v4ZqF~RoV7>9vfpfM`naQ&h0$X}ybz4f4DVKHx9U2nIiA|& zxn&Z|W+qjKQ+pUEBzZG2@!!D0DvXD(Z|m7KJ%WA0{p)eTyGstNonUBi#%}wf!2LfBZOV#5mv1?G zV7AHueNC&~-USTbL;vsjaPs0anYALN_ji0bEwSj%+FgZoBFa^$=@U%C5 z-Op!p%HREbzM#Et>5C=P^Cq%1EnD~N)tc?^e!bpsT<`arEt}6-M0#FX_xs(R@9%!U zKQK8{68O0sPF&vHu>qgKc6qS>)XCb;?vdYX`k=3>;L<)+1=Xa z=Zod*|IN8qKh^lh>*Mrvl+}EP8ULAs85j)?Ffn~-U~*Al6wNrm zVpY({pQFH{u5pmd=tGm>5d~J$jDtK?1`n8ED)kn?PqQ_vcNRyCJI z?syAl$yFEH3{xKRmd@LEjmke10^Sw-AImGQQ!#R*|blc#?D;c7eKW><>YX60`hswR_9`bGq8 zmfip2nZ?_O-Qi`QXUngAs+*hHA0>5U9_vAM8^=ZePe=GYo$|Zoq$0D{WD~wA3z$?N z%L~2|3fuN^B2R4Rl^n(s9jlgPitJtN8vJP8>eMd_q;)xuOmgO`-e9;yZDn}Tt-_!w zlUUME-kk02Kk1BF4%0Ma+fIx5GcWf}`?!1>&nq=s%a8?Pp(|W`R|QV(y0X-2>&gJz zRUtF2t}YJ~UD^3^N!Y;?q0Kp0SI5pxiQxPcy0Pi&nlxYS$m3mCw|)DvwxD-)%yX~n zyM8TQlkJ)vSKJi7U9EI|!`JNi_Foc(zbj+CgWDpQy*>XE^0aO+ajw=IiYbu&X(`(G70vSnrO+pJXX+c*E;+O};+>RZRC z?CbY_ecOKEtF8m{>SSx*w5(9)vp%&&R3(JN^2t+jeJ|FZ(W@l98}#`~`5>+DGfliEE0Y&}!R ze<<>zn8lI{GAA1Udiu6}`FwEWAJ&%G%)p>?-E$7jNjIAB=@%VTJm>V50t3|^pGdbY zvyZYAT69V>#4q|WgY#d3QMim>`Jr{&~LP-JE*%vtft0wq5vQx@D2@*$k)GUoZ6A z@u-Tv^n9PS{Zj9dElZ7UGrfv-U7E75WVxyAEB|h(EA#GcS>?U=RmkR9mm2%Fu1WWO z9d-NHHPx90)5_Prj??~eU4+kUeWPto;_q8Gj@XrLoWAx=n)>gXC;pu-WPfuj>-4v+ zvHQ}uecqXyll}Yl9k%V;5Bt8m*!TO+wPRa%9p4nP<&OE}IUAqX2s`H%?2k4Stu-!< zFUs%#GJDPRKjwAsSD!rbrut&jnf39yH)AGjo4rDywByjus1HrmrH>f=-#I^DD^b%` zzV9k)Y01^qC%#OJ>C6AG^Ij@D^WHb9PYZS0*UO$>9q{GWo(bmV%4h%1DDDw|_Tbyn zyz=Fz@764?ndm55RWtQm$xp7mFMPjSCVrDGSz0Xqiud@sTJyMz%ZhDZCGcC{kQY8) z{bu45A9vGLbN7`j`Tu37=kZt3Sj|Elod@!-C| zr?~dBgB(~hCnpx^XaX@6-SG1+zk8g_j(A!v4?UUE%2$hwT`?ChTx{ zblWdJs$Kn3FT?~XI zqWN6)*&GAcI{0?DCvj^eZgg>5@`O88#ryvQzxXTMUjw-RKH%2A!N1jkUn_!J{|5Kv zaDKfA?#t!P`xu%nDk|3)G~3;1HeAs(<2u*g3r*S`&H67YEq8EjSW zmIZ8y3hcACH>i8W`(8^i`pUod1OFNpAHj>hDIsmy9gUks>SwhlOWfeA4o+eW;I{PO z-nxNn?*XoT3)-t9_-kKquRhSU)q%VI26u-<%a#RA>i;u3zFer>Cr~w4yyJgDePc$4 zuLpOaM{|`&v)PS8X2Z@b1@1W(>?;>k?_qD;n&LCBCHS+6zRz|H%W$xO~|6a2&c!=CwWyW*?-&Fe?I_qXr$ zC$>ag3u(M)((|QcmW!D0?8?+lE{)%xrv!xct5^8C-i(=CSa9#jOx3Vi=4msRYx=%k zlCb}!-&=RTsgX089DR>|Nfy76@X|&9@=iBBvt-F<)7QEr&FyU5s^R$0>`P1L77WS07h)Uz-9 zyG>_4chOJt=*c_Mutjsi-=7I9!!oO#-F(>=%6Rnb6j`W#V^RDUpT#?4i`q*jvBfD| zFW@e9w@xqesG2Ph?h&}Ffn#UUg{w<8Tr2ZlUe>(Dt#@a6U5lShd-24|xRkDW4U8r3 z?xoAvJU&;HPXAW=R<(HAw55UJAvx{k7ebe~ty-A(JfHt!euh?ILP@@p=L#P6f<8?N zX$h&!N#%k)KUefy3WbH%1zydV&Q+O-(phR?uxGf-a1_4hwcx?DCMJG0lMaK1Lrwgg zZYdoFj}CS5>RO#SA-LGRL)NiwibvsN_g)q0RWcrJsSVSZqEFR$7B4$H%Od~PnUlrK z&(CwH=i>D$S#fcZNB_N^l|{j4bW_Y+=W>Ov3N;E{?sc|ms&?qw`1N_L)?qqfo6^r# z>2jCl-rAON_f)TUcy8>Dvd@08Cq>rY++WAcET^->;NjsmVdc0rI}9HmPsm(V;v>1~ z>8Tm|+2_twetyW{r+WR)mZMoOS1k9O9oIW+>nqTnS3lp|rEhO+PrrSyclP#o_YTx^ zpZC2}UN~bBXIt&;tv`#N=PWd?_p`0~`tsW5;iygKZB z-QM5dKHNXu?_a;~@2?;4U!Ol;zyJTgzXzwyafr@UY;EL_TanPjqxT}AS-?&su|>o$ zBe7K?Zbf37Ox}ycc8Q{+Q!3fi6a)mA7gi*7>8yK^#GrNP#-bkmV;PTo%&&pYK>p_O zxZjfLof>1RQ57?YSzZdOGIg9v`kbERx^12^BMGP21kdVlX-A!g-^~fuBxl zc{y@PY9#&n+^8?R=kw0l8NLc9btn3q+PlOD;E4;%1+6wuSw7j z4BWGITTsjYwD*%8w(rr;P`P}k^pcD5oxaO!85p;)tlhB2?4+)`9_!WW%hiR)>Wg)* zp8T%1aLY-9@--Kp8kFDta`S)R+7hR@^gKNQ?!!C{#{ zxBJZrb^Y`=r_A4jJ4=7RTnW$r`}M}`)9K&tl)tZ?s!^-|@5htr`TIPUFPp#W*Xwof z>wbSwKE3bH`^)nG|NOW<|Nq}#-_QU5|Bd^>Kb9BvOkSQ$cfA%ciJv&YX7!;_pl1P# zx`*Sw58X`?XBM!TpE$@@6%eWId4S#B<=W&p_o^i}<(Z$}Z7G>%C9>*=N750^#QI`9zaoq0S#r`T5 z75V=jCmh6#dP9O9{uDcT!p-W_#0fo1RMb6BdPRMjG~>*YHUB>H`>OR%-Y>wR?kedO z*!M|%?vo{2t}jn|t__;H;Y|udoaE`KQ=6vtZA#UNTzNX`(Wk)0Z!;?vs|~7NS?X%Cl4eY02(%-!aY?ocmyzVV!JAWNvS!}ZFurN1`I zeoN4Bco3j)luem^|4*9T^NK*1o6S6LFJx$c6#BN~hYUg{R}mO0Rc{D?3kwteD)Dac;sdo$5V(E6;gpgj8EyT^03p zRm9m9)%DID1QA>#nV93sP6d2v0o~d28#sioL7j zKA*b2_t)0-4S!cBFnixPD76jTS^BGTw$WtT)CpU&Q{=U8o(lT5sqd_gQ_`x)(`90t zC;wfO;p`oC-fi2=1mCq;;iseg`7TY^P`fsV)$_LV|7&L3w$ELgTh1Nr;90uHQFLYG z^B=d3_m!?o?#*`W+a2>%O?+o+u3pJSt-DW!wr{<0c3s)_(|6xhecwGbMX%zx_sYeK zwka%Dd7pMUI`YaXk_rF`>)1K%)GkEQNI$S7XI!j)D z`Pc3T%-lZ?9^~23EZlRS<#qXbo^2bN_Di4oa!h>7wbErFSAWIU1g)H(B9blnd1YJ+ z!gl`Jv+%I?=D&t2lr820^DI^jOe&_-A4amBrgWF75~ z6D~)(iQK-Td~1$i>^Q?O3*TUH;zJpFjP& zzK^eLO?z!rQohBN6Mow^iHklpDWw4)iyUTx%&14xe)V6 zn|FC#SMJ#Gtu#BiHm_{G^xb!M+jl*UeOI}B_q`u^=6k;9zOT3+egE$>^UmqMADX1^ z_&#%uJ9Kn=!9u=258AQ{mUX^7{X9_Y;m&;`I)9}TpHDhDlXrjVVQc@14WK-&9-5Z=U|8 zRs3T27K5Xjd%moS{`<;RUqt6`;74xfh{NvnJ5yUL!x^nDrp2s(YP)aWH+J>~Z#TB= zzVUxbk>9VT*Z+RB+MnvXpHLJtRhI3;{k?tH*S-F}@Bh*t_W$&5{<~H5VQ-7glTyo{ z$G6M)%~{i~oAr?U?Da#3P0fE_5sk07blYePPu`lBp?zQ1>;HWd<{u)JHovX$jS@rk z`WuqXeQJBdU%uNTe&XKcx}O$Xm3Xrp4!`*>&}Lavt@`AN_YC%;=T`Rhzl^qBva4M; zX>b0SnfLp@{{JRDsrt_C=Fdmn7VG{A{Ofr4lK7ehwNe>|-6kiPzDM^x?usvaT$NKf z|Jws@!O(QIZ~ku|1m?8){Xd?>&=JPCqT$-{W-1)GZ$k=`Yck^j zzO4mqTNX6$yU?6b(YE&h*S`ZzUjw+eFw~ozXv%z1?|6fMiv#!G2kn^>9a|T)Z+p=G z)q%TsMn{PU_uq!L%KsgW9YXb+F0>aLRBtLM|5@JfVu}CD7C-63Df+?z-%FDJiqu{c zNxz=r&+3u>)g<8Ug-V|v{>3*sw>_xWFzcwl(ca_1t#PAK_C!a?4el=+y1!g#>bTLQ z`ht7?j%KqH?OzHSXH9SX_MyH01$SFU@1cy|TORFy9W-22YmTe4Z@$2twSdiaNBQ=U z0N*e_Zqf9Nu(UTGfzM0)FN<}>N~D`!PIlQ=zU_ve$kTw25)7Fd?O7+f$ZC^Z+*RJS$_%cCpOQCu z*T>g4HfMkDh)MX~bf2^Czr6i~JqJHL(Q>Q1?X{ODHoV0a`2q(it2ZqdbH`Qim9eSBIC;yt~^SQ;( zG@s8adv#NNeg)I3^ab@|UN06lt7*Mh)TVVSjmc|W`_=Mj6~P2Y2af`kh>W<@@Dl9Vb+qA--eoNJ8pX|zx#}P!v-OZ?MG*QwTlsyH2l0urdV;AXWLRf z;VWH}4%tbr5wR|3XE-T%^ z=KVFiekxYy=vBrG+*@5aFYGQAvSz+pxc7_N#Uh5;8~*2-&Su=wlYepAq&Ef^J&)Wi zyD7In_TrIfi#ZhxD~)ElJpNkdwe!lPN!&n=HvEJvMgxt{m??T+W* z>6SdnjR)BE|9m(CnQmGC=hGSU_kTVlr_MgG_{wU_y zZ@=&7)AsrQe!f}^>OpMQ|No0qLGWP2w?C8q{{#0R{&6cXuxlJ(68g}<=A*zQzTyCz z(T7I99t9Th4DS0kpUB5=hV~#nG%N5dIUYj|K{kl4|RW2FUGjCkvcw@(8`F~B5Hfuc&Zo6@`UqEp3rd{qK z7B^4!{c-M@&XTIBd1qx3v+XC(=2d;39h$h%;;hHfqQ1{i&Ooim>IWlwryOyz%PpHG|H)7~U=IeK1b7Acv;=Bwc(9yOua zEMy^ns)i%WFTedFpB8nT(Qq?gb+NDL%VIfRO%HdiOOqyjS)%5v>3;C!rD?mqEH&s| z8MZ+4@~m55mRX!#>G$-Ilc(5`*G@kj z56Rb2e%#BhibR+* zU9Z0PE$qtP+E)F_r`&zvb=}i`YN@PzR=Yl>VE6qU3_lh9pY_;x{0LJ0 zby)oGfld0$dY&vRxj8xhoqEvY-RC>kHSsQrIIMAPLsy*0)&C1T`aTC~{(L4Ow^i}K zPDjd3?Ndv{CZ2ftC(+4LGHve@Gf_j&3$JI~B8S;EMh z8E{lD$0ya}`-lGjb06_bb9<>cNj1+>+2T0E_pQ>tz%^IJUQgL*8>aU^`S$%fGw#=h z*RnoaF>LiP-x1%DtMY5H+*XC}a|Iv2Hu>NrYU1}oX!Z_$A^s;D=kPB1_sAmhz@x&e zzb<79^JY&7%4&RoKxUzL! z_QU?DIeX={G?d#vlFc{wGTX3w``&luZ@a@T>=V~r{`2Td$D+H-C-U{Ibd|cYfGeuS zkvDS&^B>2A(_w3}J}!Rlo|1j?{jG1CO&4#ewa;|0o4wPNutG@4{&RH6!SGU3NSW*EfwetFHLDn=WlE@)ex}GXjZZ`L8A=8bs}kQ)1;<-%Sdv|G})4~?SRLEGOx7M&_IvOl5HWvR}-4lGWykACT(d6 z;&5&&ZSy=Kd4|n7I5aoJ)Ft6)4#U>0b7f&M9${4+xzQ!9fufmCEbafd<`h~4HdzFe z>}ab-8B%`1z{GHZ;Te2Lc>~fhitPf*UUMWEPB?appd6#PX~~ef8VuSX{P_69WbOEK zdv<<)eqpiqe80WBO3%(_nG|KGocRi9t*b#A#_-5*i*g!o4+n)b9Y>sW{O?ZRL|mK4zXV zWqxim=h*J|xp}JW#9yaVVrxS(=h&urY-F8w^rlmnNYb`XsWUPrCHfaeE!yd79mkj6 zKQkrg)3fROZ(Ke#?*yaPQ+z|ppq+|kuU;-+(5Cfj#S$j<7mcu53F25A$qR4rCri^ZA^TYv6g9mCxtZfaYNul+skAkn;f%kvnWl#IK+(@_ji;=I`$x z_@-asc<}eZiVu5_3%3g~IDPpPv0!umr5M{^zkhswegFLa{r~?nuxUJKU=hoB(8!^N zJ#u~a-}vlZ{B6r6ui|YJHo01t=Qd3R6=+90PW^T2V$s|7S+VHbghz`+Qf^F+=?r4I zd8+q#s|S~4v_R)#-M3pFDR&&#+^LxM-z5C0eS}f_Q~MyL?WxmF)^euJNSYM6Y-V!R z&!tl{{lJrKSR$8A3o*&IBG`YKr~snCd%;k^P|e`Nz`$`G^H$7cIj=b$m?sDuIkZgz z-->zZ(aC8B$)}(N9_&_38AX-`rY$QjF7}vA;Ezr^G_Uu|vG z*XP$3+a4}E`&0bQ^|g_!-R90JaX|Jf!D@aOmsmG8zwX^2bEt^t*5U3s^8Wkw{QUgl z^6L2W`}Y3+{^9ZI`TqO&{r&ypv-tMwr>5CH`Li|IyXddvcYlWeGB@~&IN2WDFXX(n zNulNcm+$urzikOz(8&I;D6x=J?nFYfa@UIcoh36nK+9}FC6fAaUzs1~0p6XX^CiHqT`*bREuvagZPKuhPxok?&s?6oHK?lCipY}>~d2Y(iqx0&X z-CVwU?WX@+uh(q4_G{JJjrUHeYvy=PTCwl|_!48j?3|4!-Owf=c=)Cc~{n| zeLo+ae!u@8+n)6Y{__3Nlorc%nmS>}rB&JM1gvEW4~y9Ad_3B5ZS}PE2eUT0id=5< zS$jgIKd0!V#{8JIt5Ve*+eA`S43~&Rt;s4pDtv!U@j09Lp3iHf7Ms3UBs9V4n3_<= zr=l}#;%`h2_&WbJIS9JMc*)AR?9bEncJDbUw%B9i$#Zs-|9-xiw*2nrd5()+H5Loz z7_m)^cVF0gO7nT|_j^^}|DQGAcx_qh+dU73?AASedT8~IJs-vH{dm}~e(uMkzF@wc zPrI_-^xqD1uig55fxm3k^F`D3v<@--^q4lGW8K=ao9mAA{eF}2{MNQe=l^fNzuWcw z-0$~i*!r$L*n0V2)yEU^>uXlenm@IA<-uA1c79px@Bi!Twf0S1Dt_-3SRWwV9G#9t;k_uP?YP`A|8b<`MJ!k7wMcJy%_rA=JU7y+onW z*TXSQ&*u<#r^ynjP4C;@omS*yWZ$p1(}yeiMvK`0m=}jOa87KCzThOX&4J6QHlQVd zXQ9YU^9g4ko@>zMc{CyE+a}#V7uuXzW{V4-a9}sv(32r@^;_F@+nE>Fc<6UCEbLg) zy7Q_Jx9cPak@*&`0-rYesi>U3-Sc$Ci+nZJT(F|?-X z98Nm4NLIJQ`|>iU?K>~*EiEpM&WJqYv9YzE(Qv_)GcWABxBEpV6^i`+6D9O++nGt{ zjOTq=6K7od)2BD7WU*dr>UyoK0d2RoEYrRmwe6gzXYsP&IrggR{)UoQ4)L1ku=%|7 z^j&pjSsZUu&K(7lM6avUw;3g0+p{vPaM#s%+ma^1t*=6kN(FzE{i?lRLfY&4sgP}Y zTc^bPrfPSTI7#06+A6eTb?j%W!+T$euCHI49nWYTaq!jD_3gYmNxyeR>=)a%vBmdI zvM6Y$K-i|zyG#={rEpi5eA_&aS2x35`_`pN-?l9C)y)cDee2q$Z(G;(>gJ?t-@fLZ zbW+#h)LVy=)3+I3gemN0U6xA5 zUD@W_F*23kcR%1=Uvb>~-ltjL_sG9|7s@z&?l zE&Km|=59ZxJL5jIw-2g#ES^P3Yg$3t&M%eSoWwGpO!QDYDKyawRPjs^T@llIMDQj5 zlrGQCr_F|IH38EDldYNNt4cxD=M%lZQY6T<4E+J zKgJL0ce1WfaAcMHvB2Tq$Lk9kM4lagkjp3JGQU>b&*NbpUl&ILgY2p0u&xkz@a9^^ zVvpH(AYGy4a`C?!Pe5(NC611A;1N&F_(^`?u8NWAwsmI5r%JSdJcFp3}Wca4`Xv@`8Vy<)8n3Qabzp5 zIr@N@j@KN8l|*#BUhGG7ya)u&Xs;AkuXHp?A@}7+lN5Xc>MMA-6*+K52Re2XzrJ^N z_xAVqi5%!SfKiFT$MYy0==dwVs9SCq$K!V6V?Q4CDqOp&%8Aj^6<6sxn}LZzl)$*q(e{xr=68o+R%mc# zP;eLEsSspi!!cijWz0{>dE1txZrimPJ34jWZgJ{rTXI8~LDMj82dCuh8y$=LY##mS zwoSP+V`1;w11}a%lDW0%5%Q)eA(m94$NWHN?PAPe*L^*YXaO=Z=!^~o!fFh94Fn?1 zWfX1k1U2A6{V`D4_iD-!M-Q1VDU(6F;9`S38eEt-M7B(v&go*&CAL^)!AUXC97Rti z&z6^IvkTf@CWx%%*~u!&nBmyP;!rTtVLr#7m(Qp3xfz_2nC&6JI*T*s=Cf&xCa2OC z^H18yIV(Ct(Q!#ElUBxpd3+k4PQhX!S#5z*Q?eXa$Gv=&{eRE@S@Ek?m>wir2eE8; zxo+3DQ?J+WW8;3a;gFd3n~lfRy5DR%Wp?__=5tcoow_-Hllyhw-U#B~cB^Q2?zZb` zzt`$MEaP6c{mwM&b-P|Js$RF_?V;c6c0Sn^y?)R4OQ+xMd3|el{=UDDe!o9(ZQa-N z2mk+R+Hi>7c+Q7I0_HLw4-19+d_2mRZnN>2SaHwCV+!RmpN=a{_xW^E?thZOA)W1S zKAko=uJief$@QGiXDyzuF+QvP{>|s}4(z&LE;xVp`Et=ieeIV^`qFO=PW$`wd_5f) zZL{@sXnalS>Bw^5uh%1*&u+aDJze+fAvfc--)`k>zgu!E@3`*wJNActzu&ERez$0A z;`d(LZTHinYqvdU$+q3`u(g`^$HT7wZ!2z=z1IEtB=@uL&!;o=Wh1uK)Mz8R!3hzT67l|L5DLHpnixe?MO=x1Va84ew&(8%mkO zz{DUzpsaQs5oPsWf@L+AE5id<0iFz!%IZg5I@>_gkkGz2Y#Nfw)eFlsOvYFe~$haEzuhJa7%*$(YzceC?a@ z(4P5b5`UWx*OgQH2HTR7we{mN`1?|}OnPX)WXmSbK8bf3{E|y9R6Mjd7uxy6;jPBb zq;AVqI})bK9m{w+Ee6sM?Yyj(gl#E|otPs-@)g)p7zHH;jmikVkQWQEZ%GEPAG>vT zVJdQ7pTe-2VJCwWc-a^uhl~gM0>Ydh`LKJKCq7u;r{LTr+Gr0)z{bg zZoeTNadDpR=C|iox$X|jjoMt0yEoU`e4X{y^0UvjZr#4__O_~zTzqHD@*Zxh7UrME zJ!50yzHU+ZzSrLk@ANa(|NWibzxnyO1@_&1zExXZUP8Ht`EJy*17|UNA_q8?U<(Lu zUt3#QUixa!`sdgBX8%rm|NlVu^7DT8sw&?+ySzAl|KFPG*Qb_(dm^=WSl@$tBJY0u zW7T-jaOUaqd)04BpU-PzH=6OFoZIQff)@TZ@rA9zNf8Un7=%Cz2%oVXC{di2v8Y~l z-ik+Emr5<-+Vt}(5_>qVy;#sI@@U1vK9hSd9+zuw(s(lAn54&(i5~x!-RbZ&yZNL; zZer<#TyH6trBlNEKs!pFZC^S)HjMMxjHGFusx#B4Ng}O)!Pns!@nc;J7?>CY2-Q`s zBcWuwhQDO{e~#y0D2r=@2%`t?bztcIiZg#d`!l?aSdqlAcE=4b?IjTz{F2{}L_D-N zy%w?1Huu1bq;9*QAB+0j*4t?yX zJGW$9UKl*t?rfCltE=mx56}C%%PdWEo6`1Qr?-{I-mh-_{Lj~>;^V_(la=H3_WUe- zermR}+KqRcN*QKu%syW?_xHEPy9ew4i%ZP=^W*)!^|QnK<7|F^dV4*4`uw`OKfga5 zc0UkXP=14>lyS%OhI+vh(;8D0f5bQN@ZCsg63Y!cRc@t{>8$zqzwWnPn+Iopn^ zDdj3mvqgP|V zKbhoVmN|ct-!=86)&542psjJ9^Q#T2D#RF;G-$Fi%;qpTR@Af9^Tw?1jg>QIckR9T zY);WN$>($19(szm|E)C>&Fv9Me2){ESoM|Vg^C2_eN6HAu( z`FNQX)5(?nbL(DZt~kia=}@|W@x$bqK{B6gAIjvFlw=W!>Ai?a}Gi>;5wNyk4_|D`rFK0VV~Vm>$v7 z1zQRPJr>L=d37@)sSWq?i6#n_8|{^r|Vaajf#$oSa#a>9Y%#WiFpxwN3N+y!=ZipU-W4cQQ?TTLR~G`)?BzkBUvOTXj^d z-)+~?B~yc{RxX>C#I(fSI(am^=jGbs#}_?7Ona}OKbJ!U0ttMubrp0 zdg+osi&sSdzp?40*kQE3yBqi0EtkT)-)_B@)~&la<%aOm$p2e5zg#jo%jn&*XOp_$ z?R>gvx9hIwhgPrK^Lo|ob$j3M@_xVfZqU(b8BeR^7Nw%sy2^WiX` zJI}`>Lg9DbuFv`T+26MBQdjocT~#%&Pwbwg16sthX46{D&3lSY=pE<#ls5uci%r> z;^wn`a5dPz_KW=WH3lCaNbA@A5M!NW%+3w-3j$@R}cd9eAPYX0YVDTaQ+U|Tr6gdK4>}R<3q5i4xtwl}ZhXX5Dwe$Q*Wd7gI z;nKMET*oHoq_;in6QytBSx0OoEaVC%QScJn8kB z^z^)gf76a7fx*v0R6X{aXkN4b|0k(mH@*$r+wy!iGi_Ptvsqc&PClERcZ~D-oT6)< z&u7FOo2op0$(Bpc=hv}uy;#r$+P~kX*7aghm)WToi~B%}ASZEUc~8pzo9r)LKk4Gj z<#kn3S}T@xL}{&Dwyf*bs>R!?R;^xj=>Mr#tG67Bdc9`bv##vrYoDxqZa;-BdgX>A zkVTNyIlAA@EL}N&&aacF=I8veTs!}&m-pJOx3ju+x8JYw_S&{2##(R3^DggoJ71SY zuiO1*(e1n)?=MNO-}UL)Z7=J6ySA!tIHjgtaDatBr{Lh!cPp3MHoek%eR#Xmn}Q=E z{y87l2_?sDJht=Ot@kdoGmfesVh4?Vsx8;i*nG|?Cf|DPkv%W=eq9#*d5_uUHO6~v zZr?6GXa87d%RX~q-{Nh5lB%xlZ(vLC*539-a`PFD@U@?=hNZ79iJIkRooAhJET{Xj zQT&{bH>DTnmfgx(|JLmIp1L>s2WqW+x9PsU;$(WQCjI~2@AtLMwXbh}`Ha{2jM4iy zpC36G+nMZ}ATGP}QE&La9ZzNi_ief3{o7;v{RPZ=RWFWdy~$^2SzBLZ&whIFc0$nRZO^K#*6wWbd!6}ui=6zHi|762YM))s=G*)D zu9~j+;n^%!T|4R~Z92K-Q2YJo-|M-~pZY$#LaXNI`_Fpy??dm`iOrZd>EgeCzkXXD z(}-3$Dt61g%P_~O=I3Mi|6ZnDf97g6d?~v7Mor@&*Qs@7-?X`}d{ejiTe`vjuJr>k zn-h)#ZLb=ZIwk%Oy3zIS;``RmlSO7c6WaE-SF!c9cOt{r=?-%9RyDJHSt#%|lXY#) z#QbRYKVd@Ww;std{TTb9IO)#}3s+4y!7h(AN@7!2991sTin{e%^v04fZX>7MEPr8X zspy5!kth9%K24sj^ z-8*xB(Ja-|n(Cx3=Yq+S^;3 zi(WrkT3x;_c1P9UcYkMZ*AH*8`+6@Hd=_G#vhzHhokmYjPgA{bVtGC8Zq>Z&P5)%1 z-ez1}dU~GOT&Xu%SJ&q5Et9R@_U6_O&)05yXP4fd$6lRw%%^6@$44jnwf*;1Y<4*M zHe`D5yQ*C!8?K~AukZW2Yweq_+p_<^KlgWc`G-e`C(GyWv-$P))$Pso`u28z-?TS# zr|sJVJ~;lwoMN-u(5T!EU#BVL^ZdH@potH3aQqg{h=&dqa=gWQ?R zXG)iKJ)4=;ck<=(d2O@Oa^@9oy_rz8wdsEH+K&Hf@=LdPy;2F_xG9ufvint*tmd^y|7Da){D*ETsL(#U$B~e-*N4Y%cr)j*>Wj&&8@uKe$tO7RlnJG zZ_(|y+qZUfzuU3(SoZ587NOhkwta76)!FoC-tM*gc5P$Q*~Q4O^I>D`tJYVCxXf$B z5A$2!5j!I6eP-d&Rj;<|ZhFfxCuc`W$(wZtW!txJT)mayw&C%cY1Z#ge@bi9c8Y2I z-G4Ub>6>NeY|=Z8rzNG{{CvSl-1o~xH+6VRl6!A}ziQyxsSc*8xtFem=J#&B9^St8 z>-Cgw-fuV3ruTlkmA-xLw%eJ<_g=nJay|F^-HO8{{!U((xNIia*FV>-@I&rsjqZdY zVUje9ter54pamidw?04WQ8@HtVXxdH=mL?r{eL{J*}JegEa~=;TA70VpdY`Ji=#Z` zu#8@b{S2GA|3aqv%+g1d&u3JA+PQp2Ez_^(6B~uBG$ytxRlTt9H2S3;QwTjORCCUR zIPkdqtCw@;f%kP!<6O0#RKX>6ONs%lD$zZs|?iy<*#yqU^QXZ|I%Qndq+f^Ucl| znXXg_pFS5`{(h8V^Z=j3$$g6eN>LC{J-pVSgt+i=rbdzK8 z951$iU8^~E!;VSw%H^CULZhSNmF9)jH- z*7RTbxkwv(@lD~3t{KC%W(;4b4X5-py2yLi*4NiJB%hw=yL;Q)+dEL!mQr`?@o17l zF4#vGd5UxjN|FL`q_>*_0U&1eRO2}}jSZ+iYXJD9^*Pv>w-P=-$zfT zgluD7>hSi)rw8``ZwNQoZmamYz;^9~OJ39Xw+S(5ZkwUGta=olsukJ ziMX}+seP!F#M3E^6)T@$p7)XYeC{Q*O)B`h1EYhaql2WQCt=WOkaQyh6T^QzXYb7M z*!akKL=>%8u-2Ua&U1W|bULwLfSH)Hb@3Ff*IaNNL&|yE-c4=h3|sbpmrptPbNx8q z<8w|!RdUIeMNb?4=Lj{}u8ojM@#m%64h-b(JaP4W3TUw27^ryGOM zt8{|r@5||lC0t^ub1}C=t#D192X7LOE>6I|IAI6FaRznk%8m?-&}R*yeHb(yQwS zI{s_q>1Y@%vSkQT@lY%lQaaQrb#lw^jtleFmZf-9>Tn7<><&{G-sQrP@ci&3{?UVm zajgLwZLbm8USnj?X<%?*xW#Y;d|U}L^vET4Pt}-=fJEmu4rQT|hzXBeI>hC@?noSX zk#r|6p7YhupsoBMlf-qqa&rynideLXL3f7SnQvN}5q z?yt$+>@{&x%f`pYCdhmD`BZLtdTNG#_PH~a@FSPLJk{+y=*4&d%L3ie4dt%yM-nkdODduZ=)1L?lld@S&SYD@$Nxe4odaZ zOuCd@ZB|sNJT2T-YXVEq1VIMLK($8omf07MN6lWlMOi_!>)%dS?Q*U3)4j}FH?c}~ zvHkz`${~#}HPbzX;ksAv_uN$pb2%9TlNt25EMyqC?!K6~=&kC7K8KWbH@OxBZAnVi z&XwbyR=Ce>#hsa7IHxx(-H_$!mAySnN=S2--m&LN&Wr(1L%%T=s7jvLdHZei7Fl+d zoa3tDce8H@%6!=O$0~fzf$x#(0);0yYYL9sk?Z+zR3d%N$73?(Z$2JZXxI64LS=f+ zr;{4X*L*sq13v)KINR~_(cE2?0q0mh#ymUcApOL6t>gEYFXx@)eM>I+NZ&QN5)!miwCB%3htj?RL)oyWj2$bCKjFg(ZutYBuGrh#e6;TEQ=4@~n%{OyBwN9sPmj6n*M7N?p1=3Qew%Fn-xn6; z@y|M^zTalS%g;;x*q?TOT|Mpp?GrC@8r7~m)mLws9rr+Fhr<=qcMaBs3s{&1cD%Nn z(A2zm0h>6-!FdG%1=5@e?9E9B1ZQ1HQMFmf6@KE77?XahUW-3lvWBz3r4P2P+Bd(> zn{r^{#SW3L!4n_+uS+~UVd5dV{{bFfSD7oRd@D#Pm?bCq#?aMhi*t+otoxhOer$Xg zsny}E$ja`V!mZG^uER}b(b8KE0uoItL=P7)7ChNtKe6RRkH+K#K5v%eQ&I(5iv9=) z?pfJMP`3W@(;htl&++pJyENQ8Svp z@=Ti1=b7hv)J&E~o^i##3HY1J)J;4I3gs38WveDl_+0$lao$nEvRjYmu})mhy;%G| z!~2Q+wZ|66|GXEz@A22-nuS$zPP|?x-rBC8FHkDC;FQOK_$`mBA6l-Pro)fuzO6PyChKOXU7fIqvY!GpO0%u7X|eDt}S4fI7p!3Qw}C zEO_-k_1Nb=cJw+C!ebVy(6}ct$dU0-MKS6~&KU%srb$ah_&)Dnj^7Oo$+CRTU7d1fzE>EG(JdR_dU7p|I{ zIG-FWtPWZ7+&rs{07#|j)AUbb@mTApH-u;&k%ls>7)744hPrh+PL9MVO ze9a@V`8y6P*BN%)|Nd~kLhw%UBO3!(pG;1AwPU5_yF!EdKf4uvN1SkzGn!Oh^F&Qs za@M+6>P+5IPM=KkxjKKE$2|A6=e=)cD%4M#p#R74h12%PbJD`P?Gb_8VHXZvT*DFj=GUDC z-%s6PI|y&)#wFhi{o9bcfBjgE?m6y-95+KKW)F6PO4%0 z=xUpFkw5-Iv)JDithQB--BCPyJuv|M+3B=VkJSHmwO?8~EvT>p0*QjdEP z_G8fl-v8^{4{-W*hqkN}TkWvpz*k{5%O#F{TLPTD^11gWZGTkX^*6_Uefh<*^Z84@ zf0pIBUK`jU_9W6#Jo-P|qE9WxRib+%TJ5tGi`Kc{dd$4sV)4;)ANu)S9&at!p)X-u zu+H9^Vc!B*28VgyTCUH1z<9yp_*Z4_xC=>qkNdt}co?2@$FINcU&Wfgcg~)^zi3_E zv9>Iy+luZ}qx@^`3n$u2y!+67{gC`Mu^*>YU;Dczv8Z!?+?THo_s(Pr zevE6Veapr#`zBNH-If)JqI)FDAMF0aUnMo6#$@TAuS%8COM?Y=T>gCbgNFT=*;)om zwwG$BivDc-CaT~yxc7WJsRyx8;w;$ic=e_ zzVM4$IIj+CRI+eUxe=uLqVdqz#NQ*GE**l$kAD_+1=Y``s8o@%ESa_Wi$ z-;WTdWywwwE!NNYElqs7H^y3b7J3N8c0n zhCk#^rKKjCwh0Pm+*B8c^@tZtYWtd!m8apIzR~%hS(dg?CD&6S+ok!}whI+X2)}6w zo5z+=FQM3T-0qHZ&IM)PH`BN5oTJWF z6D^Lgcpg(TkSnZ=(DY>R09_Z3x^dyZyndV)!{^xTEE62y_lU+#s%recIq#;>+~%0c_B!u76# z%5BbV4r?7MUao57dUdI;RPFx;&%z$LS1}8h=p0XE@HE)9oMrZeNihp&3%r^mlJewN zCXb!K3@N$j!kqA>)OP+7*$W>0d$(K)_#gB2$~Dv2pet8n-+sM#S(SI&RVU-M zUp+j+ZOg7@clXY{<+Z=o?AA@k-qJf2!Mx?ys*C^IZucxL&)sr2cKzJ#cjJ`*efO@? z*UR10=dSnj$&~QCpHF9)$E;lXut+X%)7)~tUC)=a+x>dEuzTIF=PNhAt9rS5vfZwi z8_&m8zuB@sZ}+<$_y1MB+q7NI=6&+SkLN$0P+$M&(;4&ke?DJ8Y%7oc&pjh?V$xpQ zE6w(Me_Wa@zxU^b#sByGI=A`$p5JE<@89$1)aLj8e~)Va=l=X#|Nj1ef4CR?XW=-& zD7B%1!{^?A5f2A8xeJY)F87#JZ#ZzteW?HYST4eB;$FV056ucZ3pw07Y^$I2w`ll) zo1Xmtn2I7ECUB>F9G1FeQ2pKdp6$Oxr;Rxe<%MnH1pYThI4X)w?BL&$z|uRzMNLnz z(_dzhNWaBVH7CKQ2%C9q`;}Uyt3Gxo@I3zbhVk&~KLI@%K8vLse{^pwDeNuiSuAs3 zTz8$u+a9koi-f;c9JTpX*kAufS%F>igj1MdSBTFNQT~$-@hU1zGpo5+lwOMP)I60B zJEWp&e$sQ&jm5olZMtl8F7`3Z_%LOe42M?rj8ma)n^d=LaM%9hcq(dJ^OO}*OLg)s zPeosQEWTamsdi}5+U%`|<@UOy>DY@l1W6fByKv{JQG4a2KrWRT7joRSt1`M7QjDkV z|FJ}`(9?I?i%U};hAg#WJ>*q3ZL{ivn&mdnf6lJvIWmvwpt^1TqW>oujug+gu#|JC zZrZ~jvSk62rH0zn=@;twM3lKjGd>Gt1vD$=x1XtA;m#C!u}i3^QR;1n%Ut)19d`Ol zlmu6}21W_DmrYr!|MR(H3|H{f7VZ$!rJS0FDpwV3ySUskwsWhMt6^bo(rng+nE|$& zID_&gPf6L}zH8bnp`aNPM7AARwd08E)hRMlMQ(2i3;BQbYSt>@sj04=K~ra4+qO$o zKc`mPb3%l`Os*sA{@HNEeP4BbpH$2G`mfostlk0#_{=tR*se)p-R$N2ZNvP&+@&e8 zo;OcMZQC?s@0v8@?rUeOrfr_jtD6y9Ipam5YGuYvoMsLua+ ztP9u6c6&XYHRU2Dv{-tmBS+()&mdsr0TSDrq7@9Qq}y)R@tsvh3H_vqI5eLv3TJKfqD zzx&g4_5WXm40fNKkg2z1?r9zaza^_4X!TAC+-tH__utn9&e)lGZPe^r_bi+?U!wFbL0%$;I^cdSMnUJ)^KjS{yU zpL*5o7V~OJwG9Omd9>!e+Z4-Kyvj^cXM#|8^y=G}x7D)tdPgSxUU@4^e53Er5bue% z_gzgZkT&WGDXg_V`}E(EFzL{@zqzDV-m)^Dp#u9N+H2lqc z`Ar{ts$&$7w03N0<$l$nv;ESH|NnHef3s)rdQ`S`^VBUd=DM~&=Uv&iTCQ}3Bk%1= ztM@#e&-UwN>$~Df=f$r5yJC4-TI%zJ`adrN{4=jC_xrjk`tPfV^SiEXKlgRr`m`;J zc&%?7*Za0<^4~XU{??g$_eE^m{r7D_|L!}_|4AMeo3?>@WA(j{b>aJ-n{F&;|8tM^ ze)*yQd*8O!^DGHm_vV=Su6_OW`A=le|2P@F@6!zb%4g#1e;#r8cI}Mr%QcSwV=mU$ zy;#b>_f_!us!O}&zOI+IeUq+V{q&ZY!I~?}?KJOKp8IXLZ#H|~`_}bW?#9>sm{9I> ze6`W1bIxBB&x-fhKRd@A^(Im5`g2$N9e&=1YZQ618210ys4%SUi20*nf2?A&^P&5n z*>C)KC*tvw*`VHPalPUH6j!Nl^Vv+#DDiG`s5d+&V7)AlZDQ)nl%&ezzHgsIY<0=t z`oaC#y!M|$;}`dO^R&iK(;I~~1oj2+?{#Qo@@U)_P$%!f{UxA@$wEN>M$?uBO<&U` z{snOVZfDCh;QnvU{?y#}npmRCvu5dKiOaY6Trcv{yyoWOk)XEC>r_h89+kxFDNf90 zEn5YWw+b+PW$^v_fIB>bpD}=Yn}c`kkJgPQ&78-$zFgoAozeR5L!+r!o5T%%)re%( z7u*41+*>!aZrRXYD$(5Xk^9$jP>Xrb0xpJ(Mz=7p8!pKQRSNC@SF~;n;r8E_dcY*% z?Sr=GPkfy%@|HZ|elC)_i$!Z|ijUW~R)rny)e-z97x?!kByYLUwdDf8#tZH>0o7V^e!%$@I|2eP)7IeiV0?B&8r`@HwgM~ znZdTwfPMFYdPa-d$ZIK_X02yi(wN=6AO1*LaD^{T%@sZr zvz+bEc6JF*!Nf`-iOz}o8ci9MjhQ>AWmPtQx;`yOvx)sBe+lRGtjzA7ME;x&T-gVv zuRg$bTRnQEsep<`dzFjWr)d z{AXuY^ce=eo-%_yY}PYz;X<*TxrtG3Zt1Voa@J=SzX|DTpEi>_wSil3mey21v8A)O zSkC6Y5ulKs-Q5`Ye}eP6uMIC$ynBkg9ZXVGJAG!|nB!$3@c5dK=}AA^lD>^C0sa#8 zLjRxf6|=RTev)#nGBK#a@BgQkw+Ea#uld-Tq}+Ci5#Ev6GtpJlG-a!R&yC7?&ocw{ zUd|UhmU^qw_y14L{VkqdH+=g{1h~7jIHxwqUiF>yqT}-q-|bhp{vYsW4&(PXb#rAa z+#AAu-Xyu_iF+5LE$hcQZ%w@7R?Op2o5RF7rz9;UA*{u7sptPg3uJc1czoku%i!ar zwd6qvzgt(|1LqW(UyHTHe4V&_CUGoX{KZkbs`#u)!^E%i9!*{nE&z%Kfi>pPv;_8mtHgX zpk>#yOtuG!{$Dz0XJqEb#Uic5;|F;pT%c!E{yA$RyFxqP04rhS5Ief zES#b6a5c-r8I0FzBvyv(wGup2C7yeQ`^y3L%?G&eNXti*!vlO_sJm#ErUh;L; z?AMol&u{kGoi%69sb%$6CC*2jUmVHgi4NR%f$K;_Q%gzmNzKL?U;IU1_#e0E5slbX z9y+r&u{SHBdGiMD#0Aazu6DhlY5d+C^H~lq%w&t^+7Y9cva!d=?V(6yd{q|b)+It^ zx>q?@oG9^%J>~5DTl2cm+z**fY@XeJpLCY|`Rm*`oGq!1VPu+6-THmPjn# zzETW%g&dJR&7pkKsS2aT)JZK1T6|NMu-tHJ3u$`luq|WCCmY63zUP?({FM?bg&tjE z&HMi_?oU;dN{U%)x$d5oQ*@bju2x^VNA&yCwVw{(Uu4ty_YLC>C$BnAi|6x;8C534 z*D?Oten;)DC96YqV&I98#^{_RdC5Byg%~vdcs`I3P-5HAS;Y0}`&8>_0(A>I2|jGEA>_DeB!|1r0UoodC+{q zv;)2@D$`?+O?_OHI4$$p%#2kmX|r;+y<8TT!Xof|cF8r!4R_O~6~B7L4MYCQmo)Nc8W6|5#Q<=+Wz4LhKG12Rl#){@;SuY&A zqJCvAo80$5DroWgd9PNk+Oloe%C*~$ow_yYKI?9k!XtI;IU5giW$SD@tuva0f=1W@3*KWRA?|FAgJAd!z zXWj4jegBsIe*dpu(gp|q2b6Mfkh+`Iejo?`wz-=~%D|NCS6bp5)U=96>!b-#t=7yZ6++DoYaQJ1o&yz39gW1*Mo1_HvtC+e`$)d=RWpToLMaQ{l#*g4->TlemoBOb3=@At5SE9 z376(Kb>*f%N)tU4kN&bTQIO>2m^fh%=bp4HbDTHz^L%4SRcmxw&+zB4v;424O6Ci_ zf_DWeME5+^cIOoCT_`kV2T$tTjL6eHhAV1~vfLTM#7Qxv4WKL3@H(m!ibu!6DcN^|a4XJ?6B zSZ1{5rG>xL0x>p@R`oea+|#Q({P#_0Vw!itJ z?%_D=R?}*i*!d2AFRnQIZIt!%efh7=GjyYz&}!xoZEyBR$M&uIxSs7}b^>Fxz(KKT z8w^}^;<=T*3-rRKO{jg7)GB%NSlgFPQ;)7mGd_LuY}K~S3;wRjaQ42H#kXt$%U+JG zPOZt+8@_Gb(7QG#-TU^f|F^zv%P`c*O74p8nz3d3+YCQ1)k|&nwq03kvh`&^pqBtY z>${!DLRCerZysWtxQMr#N41&dlBd_UCXrPlOAEzI!gkqxaArJpzQN3KOIKvUvK@PJ zHJ@v#|6_eIX}QVz7 zD+87=ZJcE^`{o($;+_SJc|9^mn=bYqymUlfZDywWtTU}+@5)|eeY(Md2?>uJNNng z7nUsxn0;S3O7FVRQMYoQ_}Le3*1s-J=-aYH-S?$e^sh^bCkuT{d+wM?7EPG!~tF^_khpY-#n#x;dD(~czG z)h}9C{@nDY(?r^6e}b#jvbk)gE1Y;YePCT#e>dBusCtGCZ-W7M)2)Y6DNWKfCwA=D z6$tpfSnO=w_ig9bzTpv8xwmzNd)xH2x$##H-@ldCV;>cmYqG>qY&XYY8IQf!OZdJo z4J%!9Gadx1obYl~i8 znH>04?ON`_FT!?z*YxLD>Q*^&TU}`u`?7*n_sYEds4vZYTRt4U_4{F2)SAC<3ct7O zM>v+v>W+O|_P$&@>tg@j?@e-h6I^vKo#TpMQY+f6zB`F+UR&&nB)-i7&Qebf6fE*I zU2c5${((TLGi;)@OyTryW3h}o8>ioSI7#!2@5=37cx%Wb@gxm)W=1s)rKZ6y(u+6pK&*D zuilZ)&mdm>{#wOR@#1e6iZs%>|4ne*-%xbycqwm2qyO@pVA zPe!rRi5A8S{Qm@sgEERYJ!^4DFTN}uRiDV8ZNQbKz;$gK+m{1@87w&>A6>)JQg@XK zylE52@(^%&oUP>UR>zpN_89lx1?_tU3Zew_MGfoDnYFMhcFdS2@g=!~bzzgZVwU#* z!s1oSvX*^|W?GoVW0=)aA<0vb_w1WM)>HxIPzRxlotz(2wy9YyiAbGqA-HFoWX$ry zeAc4KEiva+9UpFqdaY6<5H8RV)^5()Ey*5xh@<=EHYri_MvW__-s$m+R@7^nlxnk< zR<_!|onBhGG^BKE{ip3!U)t@JZ`cRBJLG)rjJ(RHx4l%oC4AjvzHxz!M*0YM}%uh;MKZw8qTh+y}`>vf|rJC@2o0T^M317ufZiQ)y|BbwbBih!koHc)E zQu9Kt-2q)^U3{Du`?x53J8w^1wxnpbr1uBqyx1>Rf!h)foD9Ez#bedVu!A!LMan(g zRJvxf%s#^I?ZB+>^Pih%nPgDsBBN*tLFdMGWOY0^4bdvX5Dul(#K4&qZ1zHPVj+TJj~yh`ke2J8*revR2K*NbVx_Wu4FSkEqw=gcmmj25dUOy+?ul%gmWS%&T3l*I67b z;dL^}xZ3&L)b)>MIOD5%9KWiV{|9m#Tq%uy!u=pMKAP2=PpazW3%)azA%0N~;#)lb zJl1PI&gHPZOzZ^P|Fk|vhPsdwY_DJPYuw8F@TqI{u2nm}=kK^xR9Gp#R)CH1LQ~=b zHbw*Xrk~yWEc~4;+jmO%UGJFl;EStPx!)ts&~+|8=d-+=%ah-J%V!F#U^=+upF*oo zS4*$Cz`lmIhqD@A?reFj-qMnobIq(R^@VW6%9NvCLAgH#o^wg_Il@Z&wP21HuZHB+RUFSaeZDGN#vJF++7kLEmWUlzo zR8~^3sP=nVae8@v+vR_N@h>O9Ges)VMEGpOJI?`m$Q>niThnBj5S?PSoU9)g0np zc2s@mirrj$ChY#;roX^#YaUzjx`k!yJGP$`FaGi%|MhXMo&R4o?7HO|_*GLhK z;&kv z^GAzQMC-#PE=QwwzLQ!PyyHMnMA1983a=mekGfjII$A%NR#ox<1s#-e3ZVIq;yL!~*Equ0t zy}ONBal>TJBl81&yppAUc8M!2_LQG^=tPQCq|BDaqnqr!n=Ne86I%l6TaO*H+;v{^ zSX|jL)n&&LWai6mO)O10mbxtUN_S-1oox%V=a`*HyVR1VaV8*g+VRSo6Rqq#frZb| z#s+K{ZlEoI73|r!9tZ`(KT>7t=S;1Wnnw|^L?wnl3w3uy%lta$V z_36jEO0`1YUf&-3_|RPK?d!stQ~v$^b-X-3eqR;iZ{9!K4IUnD7Uqu2i6~4y)}d(K z_h*OE)66fI+kPU3AOCcj*FKvM^3-jEHG>PJ%@X)84p`8O*EHjlPQ~Vr-dnWg+h}` zUq(_r!!nHpU98hO9(5UPt9aDFxldzp{FPkA3C(ifUMS~o`>XL}!n(`|*@+%%ky0Iu zTih3S`=2|J)Ejuq#T(;2>*?0V%eU+lk#w&UhQuU@WYyLw7<-KI}otJiOP z#`R|1A+g)9*E7G2xHO;f$LG_l)^F0vUA;BbS~vJc+UmF4ZXBw7wd_ut_PZTV7O~{* ze6eh`-X_K)UdMKQIHvu6&zEc2@Av+Aw)*`(Mvc?&_cO5Td^o_O{3QP%hx(cihj`52 zd^jxNuJiGTNO;c2qZ0a8xHMC|W`8{X=wFh6X2~|8h7$_Q&*&yFO%zhsWDLnNI{tjy z>Q85tH~u-VCS87KiS7HcT~g;<)R!2a`G4SyVTQ-{urHT4)SVJP!??qT?SjMh-AmU6 z+}9~x-o7n+-3yy}cAcx4npc&zYW+IX z%(b)6#lMlCKhNsUmgnn!AD;N$FK-1;rMG#we7>S* zzwOgiv;03hru;H~wSsA)=D!(FytC@&PuA1=_w9*X)bbS{7Hg%SF;vca^?Su7{VZ#x z#kcmKTx1k!ea7v;X2}f=95xG>gijn`wc5}qP_uwVQA6*g?dp{3Pm*kBkMI9v$=@t~ zCSgU(@qPCVmzQ%%CUAZZ-M(w-kyh_ z+1i=wQj0EU6qt0H@1MEaaoXAGyYF1Ry~Wcn^58|U{L7Lxw={ij-z+YP*XFf7J@fx* z-&t#OI_{)ce3vx6a<{mI>8iRT_onF$S}s}4D{m`q+Plja^mAA9s3XTKMT+Ek zH9dZRThr7NvP8o+lTY*1#i<4Ei*NA87Q{)$JP zIlLE|+dkrLX<=C?79-wtXxCMx)f%6V#|kI$uaR2PTlLm`bCd)_{Ex%pUmeyYK7ZOD z)*#B8eAiXtS;DoQQLmfpX2*SUU|scMVrR?ZFJY35?CMie!=k3H?TxhT-m&J}*{$nx zU3F^`bvOTCc2#cciM6(W7Di=WeY~zC^=Rz&sCB!3E?T|vgO>ggt?smqrVPw$CNJH0 zeEIoR`SoS56c+s}cl`I!v*P07koiBBXuQ6+Qm*6^>q?d&?y4=@#kVYQw#|4L7qw-( z*#5WdvKbG#qb@9x|Nk|=`fv7)$A_ki?f<*B_V4NYhfj&ET=Rm}_Ca-g*pEYdS6Ark zK3aO+t2mjn9aZ2FgX6#JCzAv zr@g9N$#s9;ysIm+PWp%5+^fK_yeB^T$@g{B!rtV6&3edkUv&S}zR3Z%7slP1Ww~+1 zwxfx+4WFtk-g%}ow{_WRpF1U{Vry2-V2(KKkfe2OLqV?S9RHuEZY8l>7p@l7s^9;H zzy8bOn=4A!1+rqkJNTbAZVWMoILIDbxm3+wGSIp)&)x4K=Qf6ai)v_ol4 z_JZT_ftRk7{#-QUc#nqXrBxF)pIUu#&kNTBzc)rqy&84f=VsQf?RyKFO!h4QUA4nO zMUG*S*^x&)2P^z%EGcY^*z=h8A>YxB76x6vc5mkSpQ^m3;rOwv(pUaMtYQDQtjJGZ zH?`k*p82WkQfJy5zlAVAIhR^%Te9%A=Z!AzvKZf27ZamTKU&N(HM8FLt846%S7Vw8+(Fo|W6ReXZTs?sc^<_@iG=`z!YUs&#tlWC5}G z2TGQ=O}sun|9|P_m$rXay(iaIF>j7zh~hNc*y;0oa^8K09|5McQOtz?eKeb#Y^~#{|lCMmMP5!7KPgu`~6Ju`=289Z4wjX0yeF3 z>n|6z{%`brcTnc9qsR8eO6!eV-yYHl?@%}Rq|^0CbDE5d7R@;6>{A6-~{!)jkAKA~ZXzo4Q?zGru-FJQmQ}you zi!>SzwpTdWH3ah4Gf7m1W~>Pco5<1WCDwSgs56zR>;HwEH3AIrZ5<0Fa^D^oiJQuG zTD2kWxQs>^x8dScyVj&-MLLB+{C1CxZ#=JDnWXjND&PD?>0QT4{}+@eE{}_2FJB{2 zUd@o=?sw`4aY? zIW}Wi*3P9_ISpA0o~6bdWBax=eCO1N$f=q0p4qNo%dR%n+xj3>L5!`uto=fH)a~zq zH{FA7t832L8YR3vw=ub+Bz*e0Qlrz`<*sgv5a{SS8&EV;Fk-f!5)`j_;$|3%4+$Nmy7mf+&PZi#{sX(D!K*n8&TfOL( zi1x0Z(`6!N99UVf`(^ewqYl2Uje*JyKUMrV3+ra3_z0Lbd|lG8@lY|3MSb&>=0jha zjVr8Px;S!#GzkB{(##x~w7048$xc5h4a3C~bC=%Wek;_twIw3j!lbrPP4}hF&J>B5 zWmy|GxBfej(sot+o#cXZldQgnDS6BYSl4KHJcE1wjA$RW&Lt5GI%l+R{9-C5mKe#} zxj3Rrs&m%j7luna(mbbyriFIxD+pg~5!3Obxkhn*SVePqN6RryLk;7^e@}IOil&B6 zOO$;%DS2mnd8^SMZ?) zpW0Tf8mp>Wu63kuc3Ni8vT)98Q>RW#*fJq;P89!!DE>7MG7o)?{*utU;MuAL-=fz9 zg!z4)Epb%)!L{65Y17Vyp{-mgdL*KW!B1komoH!G?wF|Id`Rs*tfhEp^+e zS+$~-^XLMr7n@CAZE};|VrsqFpi4U7^p=f=Tbz~kSU>p(c`O#6t@@*-l`Uxg;%23= zh}KpKQ>DeK>9+!6tyRst8&tS`9&0vdKW>gYJ$Gx;=Kq~5Pjb%vR?^~|9Z)r6yFtf> zTTN1o)r+kk`u6M=V%e=1*S%x^%0RBu4eH(bsuMT;ytVVorh41JxokIf+z{INBg&F( z>N17d#;zU>ZofCwc6)07wmhn}qf)}LYWM8s+YUk1JN9P#?cKeuDtU2^_a1{LkKAb0 zQ?r#;Xm63v-nyP~Z^-ZUxsUg-w{8_|+QpQ*yK(j|_9b&~hi30#H{IcPxnpe!f4{fk zg4M-mHRs;hv`_Q-JcFetvGT^L)L%R$pIV+nl_duVwX=`Ui)*G4C7QzkT|F-G6@n z`270*`Tg~O;_v2eZLMbkA9DLD{d~lKnJWt%`Nc#O9fkQq7CMNtO?cSGSH`ihU8u=p zVTahHj)k34i%u-e2VX&8_P1z458K6v3B4>2Ehh9aeUxbEcQ~h!((8OLBBjs$-Heoe z?|(N^CK$MBKAjQ*I>$@*=40h)u{V>wt(kQ-Cd^>e$w-@#y3A8;y4%epe{IGZ&2;N{ zt}9*B8MinspI)-Hlan>Nhjc(Zx`vKwmxj)+yi4c(mg`t9}yZQAd;*Y@2G(Oz;V z^IhkhS(LZO<>hUzIq{Pc&+q;t=6b4W$&E<{`+kVsdB4B> zoVLLnK4#Tt)4#b?q#d=l{c(Qdfufu#6{n}^eA@70?wyZYwynBjctT$K_meX&*LdEa z^|&?1_?*|PJI3dIe#w|zsGK{q@K~~6#+Q}eDEBpA4NLF+dQGp~cG9Y4uUN`v&6t|K z_Ik$h-slrK;kUot&O6Tg{Z7$!-|u(Jp7(ygSM~k>+4AjK?BK12cA+e7pIICqStlN- zn3`sP?!pbWUf-QhOfTIGSjkm5D|z`0e?N_t300nkD+`poR_Ycmd$-KC=Go7xHw|re z7p9l@?Rq}HOlj5gxjV~O?Vi0);?>L9m2r6$FN?CQH0LyLJM?=&$ESCD7MEV)-1|29 zQpMk;bCTlsF1OE$vRnQz`IOan)=bGV*Hep6{X5Q^x_ZK2F{ZrTYtP?0|9{J+plknq zj@f>FaLVD0_BGpc!Uz5bYTK$_mVfw*U2aC|>84{r*ESt#KFB7k-T2dbqN@tWA%RYV zFx4q1Yy(;ZgqD41HRw@HJRxG=dL^KZCDT<}XxfCZgC|=JUF4Sj_h`4e+1cg8X+8H# zl(^-$ze=%lXG!c8+Q#X$^QB*ofUqRn)pe~mo#HLl$n4@XJ@!P9EB3`A!F~6hEPcT9 zrtOXLl-(1`-CCx;ET19NabVsGgKEt+oo})i@A}uO|5EY2c!AgTm+wklHRkE{u(Les zP?#w&E%J3^`r-I3Mx37gwIAFiRrpVQ`tBHAYt*B&cmea`ryD;71Vszx%w*g8^i=pK zuc_R36iyaQ-57N%C`^6Hg;tfHr;?;T&p6?;%xF4lPrHnF!Zi_&y=#_i&C5GG`@x@Q zS3aq*mbQML^T_4-v2RSOi?$Z0fA|w=({ARkF1tiDh(X!*>B;}+Ykysy_b%s>tCX0k z;xoGcin+2^4kOsOC4Ikwy3K~{GUdb7w4pq z#x(&7zPcVk;$N;XsC-?;zf#L}QI$m4l837^1DbU%DR8fzrdgETHKFHPyTJEKJ|C}5 zg3-6H?`c1<)jcrxmHviT{ZT)X*E2J|ivO%75PxN{cm(T;$oi1}s4IuXt7JP8)xEh- zNG)3*@5&sRKC#WxWNXO1-&f{Gn6G-=>sPu$H}^vdi}lS+VPCi3A}i0Rs2TltJSDep z1?$RJf(^$XZIi2go13pF9dmEhIJiaoT z`}nUWu?JVzrLsT18nVJsbWZ?lB%`&aedwuxTBdoQ8PaZf{XMZao@@RqBiCJbesXR- zc=__2CoYD^zctA{Sn6u~;zC5#l_s&hE7)~^IdVVSbZ8gXxed+IuleIQ96H=rs#78V zC*g!$_`$sitbXA<4^KHo92Ho1+~@hD{;(y9{F`nZ)#=h|KQAX(eP;1JN8j=bFV3C| zTyiR8@d|fk)kp6tm>cJb-n^l)KyQUuTdhGi@9jGr(Q#XH1SOXMfm%xMdG(3=K52OE6m$G{^)HF`EjyeVd~S>kyFpUNwwa6^Fmo!^6!PQ z>CRuz?9uJ9yhh9of*A`qSv*q-!zr~X_H+N|+ zU1hiW)t!wFf1hAC>vvmoY3HKw-)b{e4da4$+}Lw>Ue)`Chr8``L%o8hE{dr0 zw}1JYPvX?fQ+H>4TAgx#qhW=ETES@3B$dx8J9D%G?`?mu_Bwee4CNUd&Y)SHL9 zF0Qndd&B4Y;De;md-hiCcX8RjY<$mm%nRQ4e|DCl){DIU{l`B4ySIAIlO1>ajqW?k z&wt`y?I-QOe~bLvepB~d>dH|kD@CjeovL01S34|I`*~D-Q@Q(YCRP_`nS1=Gu-p)qk>uHEtSSWAGQ^gJT7pPFp7QN;_KnCSW$k9h>lHp z>+U07oI;-O6D36?oMJuXxQ{ppq?FAtH+MZ@7Jt2Db%|$8gqr_yNux{Le-;GqIlymg zS~^SJVa<}FGmlCaf2llU)bZ!Cqo0!31*PKrj$*NLNr(TcT-ge2u1ndH8Y0}%oh&RW zsyOlkZa9dGw|c&)nGiOX$ zZ1tYmD(zB;iHn(Qibw5s&BrI)cy5F&eQa4<8oMSyZ9;0K=h0~SWmXqdwZ3MA30zA& zrkeF{LH4JJoZP^;=TqZSQbTufsNFhTv&KB0ZAO*RqWUN0uCF?(cBS)golsS)9xt}D zVbx{zqn}leI%_JQoUm6}`6zRwup9f90PZtalQ^&O^Oz;(C?uNv=ZxdH7Q!|yLDVft zzcYdFSZGCKyDtk?U$-!pa5BHKO9 zlM0ejiOpNnjgSt!QH0 zvxF@d#0zewmrG= zfv_wE_C!%O@0;CbD)!Tao7gW-J!d)heur9ESmV`-CQsK~-_YF7r*j{P&JO?obgqDS z?x}0yx6Qb+7&;j@uBKBA)VvguP`>D9(Bi)b7qK1j zGyks5e<{3v>BK9cj*`nK8JR7$5ciH#D-2g%8Z;{{c-Q>jVM+C@a(-25lH%EItPzI7 zOL)5!joUj4q;&J8ijSp>1-ZwrSsBZ=O<~W|ezmkvS+z)?9m*w_6y(K{KhMZrbWK6- ze_8}%X5@iXwR@^_SQuj?*kaz#2w4;sQL`#te^;f;t+pR&i%YA-PF#;xG@Ftdl>Ojn zaMj5q`}IdNIT{OQ{8+$mxVCWN#Fc6Sy`fo?vqIN= zh~8F^SnIm+@9_zR+b6C$kQnbY`{dDCYZB5IsU@vW=;4x1THBDuE1lGTl`k?ab?byp zqSmSx+~z&|pP3s}Dqwy(+)O&%ZtG^V-#Hg&MFa;ep3{-X7q~=r`urcpaf#t2YRwxq z9$d(D#oXoRf=_Lpf~S@g7%#TnwQ$uHvrwgu(?vz^9+v7@EpJ*Om!(~*Q>4IQp11Iq zob4-lmf6yY84hX7TR9KO2Yzbjx#GQXNvCpY+o~;FlRb<&y|=#FxFbfp)j(;hN@DYu zwBUm&JM|_n*xIzy-nGbj*Ai}_S=HN@&fYa^q2J3ZyVk5W4KCH%Sgp4C z^{#EdcWu}9>sQ{n>h|u(PE)2!?-49)^1CXrTzk)P?UrY4oeMkmXnopq`t}|MtGy?q z_v%~`Rhzn3W$WHsr_G~p@4fR%df$WFIt^cyPi5?L4&GNBD##RvGS!lams z4Ol9LnndjHyM@g9dN`Beh?C3_7n>t)F-JUfjubA4jCT!Ln<&9SsUhjaEE%ab{tp>sUz%#ngQY?W+# zDp9t3KVV>DxWMoi<)k6-MEiz?hucBhy=MrVaO@T_&Kky7VOYQBxxCQlE&Kbupo#X& ztK-kZw|np3_ZQmz2i-u(rttuF($I>8%Kbj+{tWzf>HgYFZd{6KVcc?wtL@y)OI;;v zBP8aw?*GGlxBc6UirHPP8&3$dZu=9w$U%=UM7fv!u0%th&9fDk)N)#9JhFE&3s;)x zp>|wxlBe16C4DDsIa8;&x|u(1ib&IZFg2>|rE0_XnwOHg%x8@Rw3C*td^Rg*+skLO zk!~PVI-WSM=9^~3jLU^DpD$<;%X+b}1Lfu;FJ8Y}FXk~NaxGjsr|gwxQ_~_b*g<*> z3_5!lx*6;lR2djJdKnmRFga{kaIhJi0t^&awh1VEwRjx6ccfcF+3w7Vjf)S4$vGF@ z(b%NoQoUAZR?JQTmDAHXVh_#n+`R1UYzy#Gi{fDarqAG?#jAiMW1xN(Aar^C@lDl4eG^b%N69 z&AGESAz$$QiGhh>AD)6HgyTuV(N2kBSkT-}YD8wFLmkid`k1z{^qh{w(eVp7q?qPe?5DkFZSx%sSd?^-}-GO*a99! z%uYz{?_$7yU%--waBYA4TRl0J3lkooT-Eu3fr;S&o|sSR z7_KqDx9TLvVMU(f3DCnR*Aa|)2DTHI^#6-m-&z8;CK;&Ia#wn>LwS1kR1<8yzZvQ$Wtqw2rUr`ywV znWnc2qGsNYH#gtax4Sf1R&#YsxHrSL93wXE9FdgM#s63m&Wi<3C|pvyKS!xiB;`lM z-6=siimuvr^El?}Cbiw{EB&^`>6F-#g!F#hwOcmbon`Y3>#Z&?7WK5Hq%EFl%k?ro z&M)ib(iv&1UM`zcW_4YEUfZg`rHiIzy;@o0^(qRU8WDo=>VT(u@_^_iHH}%;IXNYS`wP`%+P??tTs1w@_k4Qz+p|AY)&TNrk6B!k5#_MnW zxp~6meJl@_I0W*AaJ%oju~;SRi-L={J?BNHNi#~WI6ut^wmQ<~m?XyX%yI7pq2~_Y zm!vJ5D!A42!fam4n_jyAt{ggL-?DDnGM9F*pBL=^8NA9^?40*gX5o}Dt<1@ss;dMh zP0jLLv20ys#3lV{VqLK-m!*YgiYTN5~yB#;}Mr0XA2AvBGObie3CbW$T6Glox+qb8(UGkW-hY^u6R>KJoa+Q*ypx--EEFPN8m< zsSJU9A^k;L6kLu^=h&DeGhR9(kDTHSlsFr&{=MTi#KShL$tY7eE!>Ls(J=ig+HCr z^0)YDc~sub*y#^TS1z5J)b}%W=CMl6i~1S!IMrt6zk9~-6?&`rY2?2fN$C}9Cv03^ z{%wm=>-_tFow`Jt|Lu%j(BXBkZPAUNpSiV#JQNSAZ9Q{RRvX*`*jlr^O-pKxkb~Bp zrGgF;Sv{NFBz6%~ zoMKyb>)ZMHJpXnsZfs)eN?5p`$0}iAlhCi{`u}WJWn|TaJ?*#McjdA7(zQ1)pK@Ho z;&9Ayu~bNw<8q-XuN+o4O*ytYlO;}Ib@uEjr=G86{2|ORe^=3~*ADx+MBc1BAa+{Q zJ%gpeP3pAT=_qT)EzWLh-`*%%<4_?rCD-w`(3Q6i_miwIt=TX!iCZ(;W8+b=wLGu$ zHXb_UowMi5x$O5_WhSmh)PW2PI;+3~O_kuGMn=$~<_ZVv*+6@GHY7T?@F{!Enejo~ zrAxwCYtM>}$?kn}&VDSKo1Pr1*YI7H;!(8Jn?X1J6#juGNV9RS*Va{6S4V8lx_fHt z>g($h4tL3VZ(DN{bf|gk>1}IoZ!dU!YOeS8b$54He13KJ^!D}l_ct(e%lYguNUYwr z=$6t{iC5EnduQLerNX!=xH@`?P2ZX;)25sc^7xz7_0u?|+DCly#7nP?7^>GsId3($ z$~v>;ztU!}9nlH-4Evk;Mc!5K_}H?9%ZM#twnA>>iiOwv{#E@t@O=IH=;?L^C98dJ zGTzW%^!Lwn+xRD|=llE1?XCR!E^vE#zTLj6&%L+*Zr;A%{>y8If9eSpEJ5iB4Xj)u zNJC+(7?>E?@Mqgat|Q`(KHS-s)BOa$Ak+VLi9hNO;KQ|~XWPd;<$sGN^v;~1Q)iz-Oj_`qDY~zWO(Rv&rzbw$RJqm$Wj0fvzyC%uck}-n$twf@v21_2a@M?EE1Om=>PlF> zdSBwH%r%_*UTUpa>=v3eJLRY5g!wrv4_0qrWUxEE?k$D z&9EYFw(hnYQK#Q-+g2r=w}bJ9(~F`-hgQAY^=8{^y)~s;;cvHp$%=lz_Z#TUl$~@#5SOqI@s@bU#7{P(1I;)r73~qTRx^(4NZDQ3{$U5j{Ez@PhrVA>hHP+tv?A^MT z!om1Vs6$CP086498WEl z`j^zNUy4fZ;JD#)g$Rn%l^`JF^J1+RnMyV4fFq~izWsqQC;8+g2i^U`0!TKf+VWBU} z10L#E^S)PY@mM8#q>bsn;1>6`DY`wJ^|`kmeN;NuzV_C{iAt`gXN0jz>aE$@c}7e( zFyau4XTGo4G@&Iqne0UiB&InneWSzW;3LKx*k&ZUqwB0j*jhP1(HTxt53bzI_Up2R zX|F@%z7(<8%^&0=m#w;XrTF*hwb8AA8NR%}dj5XI?tEz`xicjO4;Gd3`pd?gzaF;x ztDH6uQtNsG0~3P+fn0I4n|a8toZbHZ{()w0dA~j2J*wLA=k|d2sCv)$+q(<>q-6hn z#7v#Zi8(eZIW{DCNa%nD#gGOM2sELvjB9p2)Mpo*DW&OzEmu5BP3cPHnbOv|nn#7J z^DmFfX{jw!JW{kL-6%@Zj-HUzZOeS|FWj1id4jC-%jz;t zt5th#c&<^=l+iyg>WwC=W@lXnzvMKp2kCA9Wkj^*F4=PF)EwD=KhLk)pCa{W)rPhL zuB0_vPIWC@%XBr1e~sd&!>8B(`Eu#}D&&6oSb0T%I`4Yf?C;j?#) z3_3(6!l8VK8nqMQG*BXJOz>dWqc{;tKCovO%$zWhRk8AxniJCCdgatf?0@{Th_KqjhvXK&D^~QbL?~@OogKU(R+p>n01v;+407`T_7zDth5}ge7 zj1nFj6dLN;gq3_|Y!KIt+s$xBs^dcZA=AVwEs8rXB+Tkhyv7iqti5o5l)~kVg`#PF zPGOlrD+Nv4uFMJ*;f%`J>2=mYH#G*~4)w)4jGFTN17Oo9TF_YaID%#+>?ps}9uDAGsJKNx6Cd6ln(Q+BoS^+O7h)D0X*bzEM1_w|RwNn@m*G z3^%Rn4~yKSvi=1xZ?9RV$UZ&yALk>f)e5KF?EfclF0%jojf zLX!7l<)oXZ#MU-=C20puxY(!rE#t@YrE8B=?rIf@&T(|>_%>nUDiI+M#iMGBTbPry zcl~;$t+}@8hqv|SUs{ZsZgn@bG|zfn^x7mH=e0!l?UAarookc4pQ2?6jB@$gYw+0t z8=_|NYY{T+)l483%f)=gxUUh`v8dRqdE zK^I$4k7Ji^agjxv+FB3AMv)+ilMi*)bTqCKiSmeXbXy^{%&=W+`64Yw&AMw^ZBo%0 zf~>37Tv+u`imBJBZPVL|8{Q0Ge+aGJ*aj}3Czbeb!&X3F|9a}(?)E@#@JSZyOQ-LG zw+O(6^92S81{nqhj?>^K^#t+#aeq0qVpbURNEz;x@CsSs7A6(B|Br7<2V29M*u-Z) zet*wbb`9MV68mTQ#U}@1<-=EcDyOG-PG<;`oKtD|*mru?l@p$e)qH#>S+K4WT#@B# zJIOv)?M?fI_|r=wHgoM+we|I}__sT$DnYkkI6fU6<#t)$H8ra#2ZQ^}QoO0^5x=zkL!rkhb;KX46~u z)*oZOTBTZ1we|IN=A&Gr&2DVBDiYBdrGD|AlC*?}@In&M$YsOdO&9E$XL>%X`yvo= z+G&ePO21Rqt>@hiUnQ=1I~8)dxZATcdIikRW!sRh|K-c1kDfSNZnRk&#Y96Gx`OxrRF&=i}qN!MMSMR!(V$g%U%;ri`&aTyZCeO zEzwr5eqks%Nn`KPv&y%Uy|uS(xop}QD*XD}?SkXFm#!9F-y5;D?D^X7_iAplwwGrk zH+&W_Ffj-){s#}R!fuTOujGTRbL)Q)$Mtg=}kQr8ON8 zD|x<{$ybU&Hu;-W{6?mmNddOUpH)uS{AsRcE_5;NR!ddw_-|h9yOq-?O@BYpAM*o2Lq3m~u1>Ad7 z)-ng`OgJi(?sIJ&dv5ka{k0)p0>`*+9qtm@R-mJ(8I+^+spZ>-Jx9gl*6Xy22;~G7 zH^EaV9KmM4-|M#=yqM^{T5a#I zHLtFv#m|;|&EN3mW{UU!ro9`=!s5TxT>YFo!QzHVro^I1W3OJR=9PPnX=-@+%zB;@ zEFdGC>RtSX;X)6G=;VdJzcwKk02>&X7#Q%+4k)>d;Msw?Hyq!jolcw=URt|7S9vdzCsp@t?!=chORZ`di`W zODc_~oN{azn&Kw4QYR`vieFVrb_x4Y!G@*NgjzJEG8Z(yT*_QKOLN8iEG@aEj5@2d zR;}3Ae#~)R5!apA>SC77UJ78sF)SBh-amIi+AIInrOny*VasnH5U~hT z<3WRRt`nto185QEy3~Gq#y?5@4o*yx2PQcF(-4^Cs^__6l6%lKe#b(VfTvRnV=|Xc zjm+zOI(21DX6lUOte>em?Q4Y^Wa9EN1E%K7v7|c|2YIGT<*FJxBKy09=KV`s*q33{`REuONAGXi;`-WP^{40a4`3=ACU;I2Tyg8fbG{6an* z0u4^htla;)JT@*m+AYEEcZTD^eFw`;3`e9ajQTt#G5GX}M0`>?Jw?xdm5ZhDfqAv8 zk(@qNj8Rp?S-FiDifcca*pU$1=&)i^9eTD8YSup&Y)SX9>Gwp-jjUM|ZI)Diht* zj`Sz45t(w#Ay_J8=@jks+TztGw+P+K1Qz-GoB#q!Ao0XfE zo^Jaev2Tv2aKo8d7NPgfoZO7LorhKpq%%7=KR>^~xf{#LslL0nF+^DQGB^Bocy}+pO-x@RPAvH0h0Edb z^>Zz&zrVeDoc)2d0(2OgkwFKtF#&g-IC3^7+`*rap+j?!#S}XxHc>jQt#QHLPVU9| zlKm_*g(qFP{l z$X->MHY;rtr~2%oNs{Taia#XZnb5Yj^V#f%Ynm_Sm;U2iv9S49rpCf1T}d^k%w-}g zlaSlTUl<6~NyD=dc}8(|ep7sW_Ue5l`{7kRDuMRt;JySe5n!5K1cU0tSVs0!h`(Y z3(`*cJjiYN{lt}9x)UjB%ip-G&;Bq2x(LklXYqsy4t*-_Qj9Hv3=*DtHA z!tNUxFvZ{OWJ=QFA1*2hiAx$(B{U~krp-*U@?194ukGZrDH%yG(`Naso7t3*lyfq8 zdOf^<#>Ak*#K6d~j*%IB?KUHagaXLB{T8#<4j)V|ATaA=rt!NzPJAGHl&e*J&|j znUB@tuyoy#_F?1PVP*E}#p0qS&X-H3gduiVy?VKPL7UdA6-z)ntX3>r^=j3cZLeOf z-f*l_>-8F>C05^Fy7Gb%I8g4Va*N#8C=IY^ifq%*jnlPh+G` z|2_{Um779emP7Z58+bNpa8Bl#!gW#8X=Q-E8pj6HhA9jlyM%O3Z2>Lab!OshNJzNd z7&IxRb(V@##7Yk(56=ze3>P^{ud_{PNa=Q1?#ZEkK$`X7wWDRNQX*Qem#?!VGW&>0 ze9OHBYE-Iui0U{Vj$YCeI5(w7O?UPdo@q)4S1)5&ta9hxlm}nmonlVvmBj*mGq=x?eMF%HJ(t zaZi8dquELfKcy7@PcyudYd9q*N!OJbv_`UI7l zk6T@f<5V5nLiWi@%*hTC36Du)TDfgbp`gw3#H=b#6~^3Ij-bxmq%U`!W=#A(A%R_R z<;)w5=HcOZef7FotFlBImP*Ma?wmDk(W*t~qE?H{o+=gI<+OEI zQkPIimBtDa-DkIIlLEajg`1yxHZ{)tR>#%eKWDk*H^tSuMsM7;vRfzj*`!tLc5Dku zTeUMfVnyDLMW4DB?}_V_Zcur=^VX7W=B=Wx+Ot#yG!H2+eUV?lwCvaQ@ZC&m30W6b zX*>w83bhfLa%NuE!d2&!bX*s|ax2K+^+8I^;FO5>Y^}m63+F`b5O{oLWmfH1nIMs^ zt=Av*nl8Hi=;EJ~Y-`hg3;n&icPX2B#jZha_+3@TLM+uzTMfk$>RH+v)7htSCzc( z+7VKrYnOXRH*uQovpMDaE*)l)v=q2AZ+c#pTg<$5psV`c{d&FOxZdwKTdwE*e!JuO zy5H~ie1G@*{Q-9UKOc^W@1M)E`9}>UUc5M#d-*36wlKy_b zdb`N-`;+%C{@Pzzo^Sh2YHw6V*3;wjC$pUPHd+`GG$HZ4{h#=5EXpXWkR2aQ>w zVwc4=|0KuF{&94F#}-bb9M_PAkG&YUEF|vnv}q+JKFs;wvZ-}zPHUt~$*Cv}@q=wE zR%riTDD&r1s7Zujb{DTCSACw!=7k6F1 z{URwDt)FJ7J4JJ(2yf%K^=X=Wmuy~<@V(@FjP)P+pK>k{oh{(OK5_ai!L3aqLJJgX zKW*>Str1A(JtJTDu2??8Yr*vY!GY{^C(kmq`k3Jy`6#uH!=ThQEv}WtsXZj@c!tvx ztNuIQ#owIIiZMOx5qQ`gP$9Nfb5di|E1`hgt`1$xZ%ezJANlM2-Sq!R%(lt%PjoIf zd!Bl>ZN=qTI#MSr4<^rW{Zu@6k+nDH_Ob&V73bwnM@^5P(!Q^8LR0+`k3;rJnyixh zHrOw;$+Wgp*~jd4Q0{tRs6(x$0Mj8=D<{Qe@im&Fe0hzNViZ{e%5QL;stlOsTeB>F zNyqV}j>4uEyZnRrl{|K=35?>~sl|CdQpft&Q-e88Go81nZ2o&jV_u!2M*Os;I~pfE zRP4TUJ?_Ocr7L$Pws^O`I_~sj!g>j|sHi!o90X2!eC9aeyiicfaZ>ul*B|@qC;RYf z=Q=J~c*D8=`pz}BIW2)R*c7ISx~vTN-f>KBU+AdkV3*JT zx)syfKR&(o%W8JU%5JOZPEEnNUaLMHeY)WAR=(~zGX-QqCvJU}!}qkJZC2+BW5cJD zuEjfF38?#Jy4LXRsSwdk8`qzkoc;ffcDCnH=?dN>?dRuc$ID1Hw8{y_b$@wQ;&{1j zA=?7cw;djA|1WlxE?v=dZSq?E;4j=k86Rfo|8Xszq#kr@jmQ+Hg>viod=ByNz3z7Q z_Vc*D%o&&YQiW~K#5e9`y1lmI$LhbG9bF|SLY1byjt?oEnozcS!hvla{XV59lJ3k1 zlWq)kcCmSMZo-{#h4X^R-48{QwoPczP*<4lcCKhL=cXfe_xextsd$KPj5whi$JYIP z%HxDHzm7j_TK0cQt)^nJxKiV5&HnlgHJM*eneO$xwEnzGsGO?s0?!aJ_7#_fjXA9& z?)`F8w{k!7#Lo8U>HS?ClRYz9RjfRYNFB1+CbUPEG52EMOVb~XoR;!GE`;0upY&`A z|H><0QdWz0U8vf?=~%iV_W1PZWp2$=Pc2%t`uM32qFlQ~9G`iK?aF>P*Py3;rreKd z%U`GUvoV%+GHm<2DK|Ggy5hv`6zyj@xoeTMZM3~YWMnxy|c;PBsZNc`M~HtRnR1^Ra!QTO}U#crc6dVa?- z(2bhae;!NixA1fD`!s3xpC=ms|5q$nqHtx}{ZCKz`Yq3->wTVe`Oh=6^Oonz*L|M% z`OkAZeya=ZdS4a^|9#=+Z*^(y|N6cq&vu<~_0|6$ zs%{*FTnZoVUVZIqp6x{KlsB3Ezi&O=SGukI?z{NqzwdsX_kCxxe@g=Yo(JsyKMqO% z`_SRP=h59hfuq*{K2GT0^W>oUgOkz!KFv7)XO(2Vz}f15pBMa>d*JN<>r(r?&n$*} zUxn9KtUi4Aed+Vxuao(I@4de7{g(54-&oYU-_+j!{fPhmkF@EvpYrSf zT<@Rv^+Nx?hmHCIFQesuP2Iom+j{@M3_1UQFQ~Ws|KoW4uXmIG|CwCA_rvu4b$iU` z|2+xyl6DZXl3MRu%6L;qP*EEqb>DBxhP}H$7%IuE1J?d+B1K&{bz5p z_h=}qsPmf9&e_pk!%-Ldqy0sDE1yQk1NWwk7i}pkIto47yZ_Iq`S!e{_eV#kMcd?z z>c)(Y$IshmWz<&R=zMa#a{))~OpC5N?zPJ@YLg;5*J!l&ShP>!u$!3CxuK$SYeo0^ z8Qn`~bX`>M+7nSV|3~+==iP@S>W=Q{Tv5@pti5BMNB7PbJr_>Yw?uSo`_Z#jqjSfO z=G`lLmZS^bznpW2qvzp@+Itm^(jIn?C8}=E=$jSZr}v{T=ZDRkAC<4ddoSMTxh~PY z{du3%iqenCy+0%-+~Al{YSDioywCYX)6?V$v&4INURM6uQTgJ>gz4M+L?kOOS2Vip zm>@p0?&ggCZyb|~+Upy|o99&YGj~q<711nqb0YVQ9zD)VKFP_G+3^L)oS+lAN`oa@MMuv)1gKweIGu4Lc|La!ldt{_ zvs%KvIwsB(`YC+TNptp5%PlJ_Iw~s-uFmidi8tBich#A;IBZiZC{n{XWkGwOl-JB@ zKV|R#?B$s_$9SW7pX0n!JLj>ls15bBOtj4HQC5n)&eyLNBC{)NdZ@F-uF_&vk)}3J z+h4YaZfdR#;p)G}rPe8MHhw@BV9i98wGuOud*kda@gOny<1s{!&Cu%3PK!_bfqAJt$Nxm`kg(KSE-U_B<%(ZrpSyU`jm0jVT1t;H9sDhqS-f1Zm1Dj^ zR<+Q#sf9oJr~jHNti)<~%geq?LU_`YIL+1Dc1b8i+5YQv->#)pEa^3+N#bCX6AR;- zLse^>Lqn!~k~t)m;4Ql50@w7mRc0)iGlDr6Ui-zLDpe^WC35(i?OFkm+{9U~Up1wy zR@5chZrmdJ+Hi$})=Vpvb(e06ZDpEbQ?f4JMA6(NjoFv=eS#a$Y#+v~#bqYTO+Es`uyhpx1eJ27SDL;Z3))flA^bzRBu~c zvUJtk}e;LA;t9_-4#uxq{a?)BcgH)!wPk-dAf_U=8qckg<=`(X6$O}n|a1aQZ8SL}bH zb@Y+e;YWMUaPK{@z4z?vJy*5&u2jrjczxc@Q&Y>`S2_Hbs!*3s4e|1d7TDh2I`h9k zLWd%&{mIg;Sxb(lZFX6*`m5yj{Rb3-zu6y(3SwalJ{0A3I(hM?rHu}&{r-z?kxyO9 z;G?;HoAZUPEvXNu>{-B-S#U6O0+Ym=gW_inO71zRaOa@npMy#5b;4zF)7WiQ!&-K#q1cZI}P+51_xTNOkfTjiX-%(dZ( z+ueg)iPP6#`VuVjYN3_L_M6Ic8{E0>Gd4uN5xJJKVcj+P&Q;#b8|{~Vb7Wn;B5Fe< z&+qly)$C^9TH!Zw(Tdq({~mBJTEHeg=TO}owhRIGdYcmsF(+DTPPENA(eZ!JiO!mX zs~>Q;hYC3iBxQCad*h%UTm=V>&z+hF0Fp&owM=PMprA5T~+q3 zHv2j+9`IYZlldaspcINoZF~WItlFq;cefniuAR$P zC2&7cfqlyb?o|fdKkr_@IhSnrp)YloM84#W>2TxPgi>#>3y8vrbg#Xoh7h%dh6wVuTHBT*?O#NP7%*B z7Oks$@9gL05dS-SjidJ&Z^LUB_ONfBaINUVy{x_54STuY&1KsxaIx5cyLbYZ_?vr` zy4;Pu2Yt@*XD#5$e0!*x_pCV2;l}6Qed^A?l$WZS=5drPRxXuaQ?gIs-C5C^hW#-7)b9mSu%S$oX>wRGiym6dmX zFL>*I+41e8xR0wu&*|NJ_b@&xCO&f7$J6uIew!VA=j|G)1L-}R-F}NoX~f@9eP46X zi#^AFb=v0Le;zM<$i4W&K^>W!skI-}b3Sm|ADR&Rf#?49OF3`uzg~4K?qlYTAhGu~ zWqPdl7v7fTPu%}j^T8YUFSDjBHTfjm#87Le#j)WW3){KqKe9sqFMTw06PO~kVScr^ z^NKZnA9pUg^ttNWvFA%>ehQt%mwkNUL;kH7j`OTN;a7jc_y7H%`d9w(PlfhpvAQ)% zgdUl0{&3#%lY4Hh`nC8?UW>arOW2k!?`>W#Q8Q1ubUj+@Gj03BM<;@pG6wKZt~)(h z=V}LIX2P8;tAD)@AHU!J<_f37>G_V_%=*uI?*Hh!e|8wES zfBs8%^Itvq|M$-MLu<})hyTC*TG-3klT9YX`Q87F_wxTg*#G|&|No2qf9FVdK0c z}}Mx+`Ic)rNiIZ&nSI+@96AseXn}PU;n;J zOMFN;*vctyHs{BC`}!VP^Lf&dpHfccnui{n6Z!esmTKF2Hrc2zFLurGo$r<_kehXF zZRFu4w?D~c-rD*<_S@FKH^1iH%io&PvLkTE%?C%0i>c3+`T6Pb*~aiQva!EPUR6!C zPOtj=^V^#EcFYMn zBD}PvXvgjuPAfeoyfEVQ)Gw|!@)26zv&qNk=4(ctj#MMfd&$5p0=MLMU1T$W;AT6`|G z9={uStJeHp`ODVuxSF?f!{h3{U%h_6;kB&A{c2Xb84p@`--SPHlVq!SST9&tk<_ld zZbw45;xdWkervX$k0w}(?M#{Ef6pSdH+qntIQ?S?RI4@o*#DW<%)%Av;JqTTs_Y!d(HNJyRujBzjo{OhJ(*$ zt0)WKD2%B*Bg<` z|CZj&U%&VB(chNg#wnGLC9@W^POtj0q}N@la^Y0-c@@w5_3eH=pFCae*URba=lyy; zd;7lXw|kG*RlnJ0z#2`eyhuQ|C^=%B(OW~I8ZmI zE!dJ<`1}nv)4d1R=0A!~ycRdZq=GTG&A3PLQo)ODOWq3PSw2y`lr>|E*fHL`FU*hl zn@>2ac&+X5G)WR!-DV%{>rub_;Or+^%XjK#8J5hQJ@+lgcD}M5eceeSbKg0!TbRlj z^rYJ;OI&~9YH_Ksuk=j9nYg4IL)V`M0YyPGzF&H(ee+r{bhAxU4lPm9oIdlU zZJEVpHgii@u2vHrR#)1ItR@i~}zu6)~;NnbTysvg`KJo%E)(g!&i&etP% zxXt^r>{&^sWF=S7>StXk+qPx3 zf9p$+*i#{kXKkMAYpdxIyQ^#JrAaGQbk(gFt_m%Wox0jYHq&#u)aA{)LYCVd&GbDl zb$Q36&^7A3TK^^QzdE&Tmv)QHkvFOQ;ZBzy_@r;X6?wje&2QJd5>e;5sk$?*u6wN# zS)^RL#QJgQ>FcYmF0gz1GN9D^cK*I^i|ct(wN`RoTH~~3i=FRN&+RWSmt8cQ`cCD! z{KsdPcR%W#T4uW{KH9rKH}31iw$hhHYq@W}-1oWv`c!R;>8HI4<`qr8^LA}i?9X$z z`lhV2Eq?Co`TOjRP0eD_vFxhPxq`!LjOWPTO>6YEK6_8CMCOU_2h&z>zjt-tClzkh z|1ZaNvC>p1$8O3aq5W<40gBJNmYr03*dU#-r>!*qfNrvCWtitKwi%Oqbnh%P;74Nf6bA4>^-}CVrHOm9NJ%uI7drp?6O+BiwmN_L_`+<>D=mFDX zOQuVEU3Yud+oPYGJ-z+U6Z=c6w+j5-n3B2ck$Cu|WvNe>^d7Ffx_@cNTBp08XSUXO zu9y|F%W18yg}@UCGz)PjK251?YsEKz3&TO>)!eIO*-B3@`C?bU0Rk}B844OcNDam%XIPkTO5(p zD{Oba^GJAq#Zkk5CR$Q=y54&l?%Vq#)9Kj6%c{p^r2^+!Pn>O2s_Xw#;jKr}jLkJq z=dIhRP%P&*^RZy@!h2^{L~r&;i{A3$bMd!}^L<}0F`xU=|NhoXOW%E25xnOgtxDRdoHIF#!D~@T)ee5o;d8}yv^LTjNr)m8)&ot{R&t=Pfp8dV% zxugBB^V8$LEaR_zrp`n-er%5EX& z0}+}#-fq~(o+HQ>Kl{BMmNzZ7s< zcuo~dIVfIoP@?6aIcyQbVN!DV@`1ySXSyBk@EVvLxu$T$)nuy6 z8-DjW{BBc@OlvrDMdJ|n8)vI8&b}($o?l$1K5+4Qayamgi^G~Dr+yp>k?9P!;dMzl zdN$x_gwB-kD?GtVj_#arG-A)us5>r!Z(Ox_j`Eyz)qg2uaMC57=a}Ulhv+-5J0y;U z{Mx z%Vvsu>Js<=wkk*K9~>*&b1Z+3hRgfCP7PM$J}MJ!CxQ)9}(=0BoS--u4$b8M2& ziRmgQ&n0xv+B4bZ;fWnHj?FFMuHJHT!k&q`*5P;z;O>nj@CJzZ8z+< zP7$zi6rJ&8(u_A#lUYuBJ>)QEoVM5Ebc)QGE}b(?1*ewFxaaGfSaC+^rNZ&hIiB)M zd=zRN+vhm0=yBQ@(>?FWnZ7R`-javw_Skj(@!9HeX2*klSO2hID>-|u|>L2(*(}-Rh)Y^<=l%U ze*GHfUZ?oIY4Lk@$M4M-zjt%|UW=T6E5rHlP4|19b0=6jznnSuO~>z($+>$hem|F- zzaMh$!IHB#_niNG=KTFLXB)jwTm6}`F{6V?^+L}V*Y64aNhZf9Fz~XKp1GLeTgDTx z#pX@vOpW?%t_9sy_R4yzd^ku2%Pir-SHr}CBe6OgKxy!^Q?lr9-$|FJcDX$ zJeqX^4_>(x`o;Yu&xx;7&h5G2_p!#`vh%89PUxA6<6OLx;~$)5ntLtNm!n#N!_f4M z-$x#f+(RshF42EEa(Ef?r(V;Yc&+ilWt-ISj~BucVyUZ7^!6VASFZzdLO2Um zIivrcD`fTmd*$rqB_Y=)Tx)(CloNWR<3w0auVC%n1FfppCq>-&pWt|7X~n6E((X#p zi2E_u`f6`nco1GX6|EfY1!ASv8nupDN(l?jx6r&X}TM*;EH#E%Gq_Q z7sX_!98kH$uj;YlOGsf(;Fj9U35`)}r(Ri<$~}E+lq2Wa&AxthIo{$e!E5K7Ih}J! zHP>U0ZHS-iabMry9Y=hQYz>+yY_vhcU`vJm7I8G$APj60({ z?YZk2*`xmdOzwYjz5hA%{ukf)?`O_^TYGNX)vMa9*M9B2+M04wDD?ba(de%_F||uP z_p6@tU+eg#WsH+-+bb!ppBLA6d{zK7-yAMn&YIW2LaD95eP?q^5s=FFY)@y_)XYxk{G z?OoMt_tDkst3+al?;|6VB$+<96JH)`TSf{zjsN}iew?hM(y`rFYVV%@`p9odKqtqw zjel?Mb~zEMc;dgnTR!Vb@2jF`)z2Q&>r3#P5_|J@u*VXQCsRdi&&BUH30ZZ<>*3qz z*QO^g3MBmy@zK*wQa*RjBJmLq-Jfv77M z5$y#xwppaVo6%jm&P~Nl;6tsTS6XMYZ20`AY31jxo$?5eI2z%*^T|$wXG!1AH%{fw zfA^&28_yjE&bDJ$UrAiko)`E(a9ZE1EiZHyUXMKc@MlSA(!GejV?Bj&-6w5RpS*ZJ zxh=8XHM4YWVn|$O+lFgxYme{!5Z0;pP;A|Evp~)&zDK64H;UH!m84~M-ALyOi{M;$ zZK2)ECCeT!-1{)$*ps|-FB+$1RbPGaYfIM6lz!)PuIrv%Pccn+@+E`)T*d^|*ZqH= zxrPZ$xtGrT_W7fTh~)tsQ`d#f^yAA4&FU{roo6dB?_TDDZ<#e};S(D=c7+vw>?6j*#_vK96>Mw2hH1u4;WVL{6N6$*MW&U5fFRZlojf5EQ{yNb|c@vXr{A%

IL>wZxZA&Rw6XQP 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 diff --git a/lnbits/extensions/jukebox/static/spotapi1.gif b/lnbits/extensions/jukebox/static/spotapi1.gif deleted file mode 100644 index 478032c56558139e89f23adb4449fec594884ec0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 246647 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

#afAHR8@xEfy{te%>^^Qm=?n`#!! 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 Z#pBV(1fh0OekKkM>L((csf!c|YXC`rnZp18 diff --git a/lnbits/extensions/jukebox/tasks.py b/lnbits/extensions/jukebox/tasks.py deleted file mode 100644 index 65fca93d..00000000 --- a/lnbits/extensions/jukebox/tasks.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -import trio # type: ignore - -from lnbits.core.models import Payment -from lnbits.core.crud import create_payment -from lnbits.core import db as core_db -from lnbits.tasks import register_invoice_listener, internal_invoice_paid -from lnbits.helpers import urlsafe_short_hash - -from .crud import get_jukebox, update_jukebox_payment - - -async def register_listeners(): - invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) - register_invoice_listener(invoice_paid_chan_send) - await wait_for_paid_invoices(invoice_paid_chan_recv) - - -async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): - async for payment in invoice_paid_chan: - await on_invoice_paid(payment) - - -async def on_invoice_paid(payment: Payment) -> None: - if "jukebox" != payment.extra.get("tag"): - # not a jukebox invoice - return - await update_jukebox_payment(payment.payment_hash, paid=True) diff --git a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html deleted file mode 100644 index f5a91313..00000000 --- a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html +++ /dev/null @@ -1,125 +0,0 @@ - - To use this extension you need a Spotify client ID and client secret. You get - these by creating an app in the Spotify developers dashboard - here - -

Select the playlists you want people to be able to pay for, share - the frontend page, profit :)

- Made by, - benarc. - Inspired by, - pirosb3. - - - - - - - GET /jukebox/api/v1/jukebox -

Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [<jukebox_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/jukebox -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - - - - - - - - GET - /jukebox/api/v1/jukebox/<juke_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- <jukebox_object> -
Curl example
- curl -X GET {{ request.url_root }}api/v1/jukebox/<juke_id> -H - "X-Api-Key: {{ g.user.wallets[0].adminkey }}" - -
-
-
- - - - POST/PUT - /jukebox/api/v1/jukebox/ -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- <jukbox_object> -
Curl example
- curl -X POST {{ request.url_root }}api/v1/jukebox/ -d '{"user": - <string, user_id>, "title": <string>, - "wallet":<string>, "sp_user": <string, - spotify_user_account>, "sp_secret": <string, - spotify_user_secret>, "sp_access_token": <string, - not_required>, "sp_refresh_token": <string, not_required>, - "sp_device": <string, spotify_user_secret>, "sp_playlists": - <string, not_required>, "price": <integer, not_required>}' - -H "Content-type: application/json" -H "X-Api-Key: - {{g.user.wallets[0].adminkey }}" - -
-
-
- - - - DELETE - /jukebox/api/v1/jukebox/<juke_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- <jukebox_object> -
Curl example
- curl -X DELETE {{ request.url_root }}api/v1/jukebox/<juke_id> - -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" - -
-
-
diff --git a/lnbits/extensions/jukebox/templates/jukebox/error.html b/lnbits/extensions/jukebox/templates/jukebox/error.html deleted file mode 100644 index f6f7fd58..00000000 --- a/lnbits/extensions/jukebox/templates/jukebox/error.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
-

Jukebox error

-
- - -
- Ask the host to turn on the device and launch spotify -
-
-
-
-
-
- - {% endblock %} {% block scripts %} - - - - {% endblock %} -
diff --git a/lnbits/extensions/jukebox/templates/jukebox/index.html b/lnbits/extensions/jukebox/templates/jukebox/index.html deleted file mode 100644 index 9b4efbd5..00000000 --- a/lnbits/extensions/jukebox/templates/jukebox/index.html +++ /dev/null @@ -1,368 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - Add Spotify Jukebox - - {% raw %} - - - - - - - {% endraw %} - - -
- -
- - -
- {{SITE_TITLE}} jukebox extension -
-
- - - {% include "jukebox/_api_docs.html" %} - -
-
- - - - - - - - - -
-
- Continue - Continue -
-
- Cancel -
-
- -
-
- - - - To use this extension you need a Spotify client ID and client secret. - You get these by creating an app in the Spotify developers dashboard - here. - - - - - - - -
-
- Submit keys - Submit keys -
-
- Cancel -
-
- -
-
- - - - In the app go to edit-settings, set the redirect URI to this link -
- {% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw - %} Click to copy URL - -
- Settings can be found - here. - -
-
- Authorise access - Authorise access -
-
- Cancel -
-
- -
-
- - - - -
-
- Create Jukebox - Create Jukebox -
-
- Cancel -
-
-
-
-
-
- - - -
-
Shareable Jukebox QR
-
- - - -
- - Copy jukebox link - Open jukebox - Close -
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - - -{% endblock %} diff --git a/lnbits/extensions/jukebox/templates/jukebox/jukebox.html b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html deleted file mode 100644 index cb3ab49d..00000000 --- a/lnbits/extensions/jukebox/templates/jukebox/jukebox.html +++ /dev/null @@ -1,277 +0,0 @@ -{% extends "public.html" %} {% block page %} {% raw %} -
-
- - -

Currently playing

-
-
- -
-
- {{ currentPlay.name }}
- {{ currentPlay.artist }} -
-
-
-
- - - -

Pick a song

- - -
- - - - - - -
-
- - - - -
-
- -
-
- {{ receive.name }}
- {{ receive.artist }} -
-
-
-
-
- Play for {% endraw %}{{ price }}{% raw %} sats - -
-
-
- - - - - -
- Copy invoice -
-
-
-
-{% endraw %} {% endblock %} {% block scripts %} - - - -{% endblock %} diff --git a/lnbits/extensions/jukebox/views.py b/lnbits/extensions/jukebox/views.py deleted file mode 100644 index f439110a..00000000 --- a/lnbits/extensions/jukebox/views.py +++ /dev/null @@ -1,42 +0,0 @@ -import time -from datetime import datetime -from quart import g, render_template, request, jsonify, websocket -from http import HTTPStatus -import trio -from lnbits.decorators import check_user_exists, validate_uuids -from lnbits.core.models import Payment - -import json -from . import jukebox_ext -from .crud import get_jukebox -from .views_api import api_get_jukebox_device_check - - -@jukebox_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("jukebox/index.html", user=g.user) - - -@jukebox_ext.route("/") -async def connect_to_jukebox(juke_id): - jukebox = await get_jukebox(juke_id) - if not jukebox: - return "error" - deviceCheck = await api_get_jukebox_device_check(juke_id) - devices = json.loads(deviceCheck[0].text) - deviceConnected = False - for device in devices["devices"]: - if device["id"] == jukebox.sp_device.split("-")[1]: - deviceConnected = True - if deviceConnected: - return await render_template( - "jukebox/jukebox.html", - playlists=jukebox.sp_playlists.split(","), - juke_id=juke_id, - price=jukebox.price, - inkey=jukebox.inkey, - ) - else: - return await render_template("jukebox/error.html") diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py deleted file mode 100644 index 1390a019..00000000 --- a/lnbits/extensions/jukebox/views_api.py +++ /dev/null @@ -1,491 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus -import base64 -from lnbits.core.crud import get_wallet -from lnbits.core.services import create_invoice, check_invoice_status -import json - -from lnbits.decorators import api_check_wallet_key, api_validate_post_request -import httpx -from . import jukebox_ext -from .crud import ( - create_jukebox, - update_jukebox, - get_jukebox, - get_jukeboxs, - delete_jukebox, - create_jukebox_payment, - get_jukebox_payment, - update_jukebox_payment, -) -from lnbits.core.services import create_invoice, check_invoice_status - - -@jukebox_ext.route("/api/v1/jukebox", methods=["GET"]) -@api_check_wallet_key("admin") -async def api_get_jukeboxs(): - try: - return ( - jsonify( - [{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)] - ), - HTTPStatus.OK, - ) - except: - return "", HTTPStatus.NO_CONTENT - - -##################SPOTIFY AUTH##################### - - -@jukebox_ext.route("/api/v1/jukebox/spotify/cb/", methods=["GET"]) -async def api_check_credentials_callbac(juke_id): - sp_code = "" - sp_access_token = "" - sp_refresh_token = "" - try: - jukebox = await get_jukebox(juke_id) - except: - return ( - jsonify({"error": "No Jukebox"}), - HTTPStatus.FORBIDDEN, - ) - if request.args.get("code"): - sp_code = request.args.get("code") - jukebox = await update_jukebox( - juke_id=juke_id, sp_secret=jukebox.sp_secret, sp_access_token=sp_code - ) - if request.args.get("access_token"): - sp_access_token = request.args.get("access_token") - sp_refresh_token = request.args.get("refresh_token") - jukebox = await update_jukebox( - juke_id=juke_id, - sp_secret=jukebox.sp_secret, - sp_access_token=sp_access_token, - sp_refresh_token=sp_refresh_token, - ) - return "

Success!

You can close this window

" - - -@jukebox_ext.route("/api/v1/jukebox/", methods=["GET"]) -@api_check_wallet_key("admin") -async def api_check_credentials_check(juke_id): - jukebox = await get_jukebox(juke_id) - return jsonify(jukebox._asdict()), HTTPStatus.CREATED - - -@jukebox_ext.route("/api/v1/jukebox/", methods=["POST"]) -@jukebox_ext.route("/api/v1/jukebox/", methods=["PUT"]) -@api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "user": {"type": "string", "empty": False, "required": True}, - "title": {"type": "string", "empty": False, "required": True}, - "wallet": {"type": "string", "empty": False, "required": True}, - "sp_user": {"type": "string", "empty": False, "required": True}, - "sp_secret": {"type": "string", "required": True}, - "sp_access_token": {"type": "string", "required": False}, - "sp_refresh_token": {"type": "string", "required": False}, - "sp_device": {"type": "string", "required": False}, - "sp_playlists": {"type": "string", "required": False}, - "price": {"type": "string", "required": False}, - } -) -async def api_create_update_jukebox(juke_id=None): - if juke_id: - jukebox = await update_jukebox(juke_id=juke_id, inkey=g.wallet.inkey, **g.data) - else: - jukebox = await create_jukebox(inkey=g.wallet.inkey, **g.data) - - return jsonify(jukebox._asdict()), HTTPStatus.CREATED - - -@jukebox_ext.route("/api/v1/jukebox/", methods=["DELETE"]) -@api_check_wallet_key("admin") -async def api_delete_item(juke_id): - await delete_jukebox(juke_id) - try: - return ( - jsonify( - [{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)] - ), - HTTPStatus.OK, - ) - except: - return "", HTTPStatus.NO_CONTENT - - -################JUKEBOX ENDPOINTS################## - -######GET ACCESS TOKEN###### - - -@jukebox_ext.route( - "/api/v1/jukebox/jb/playlist//", methods=["GET"] -) -async def api_get_jukebox_song(juke_id, sp_playlist, retry=False): - try: - jukebox = await get_jukebox(juke_id) - except: - return ( - jsonify({"error": "No Jukebox"}), - HTTPStatus.FORBIDDEN, - ) - tracks = [] - async with httpx.AsyncClient() as client: - try: - r = await client.get( - "https://api.spotify.com/v1/playlists/" + sp_playlist + "/tracks", - timeout=40, - headers={"Authorization": "Bearer " + jukebox.sp_access_token}, - ) - if "items" not in r.json(): - if r.status_code == 401: - token = await api_get_token(juke_id) - if token == False: - return False - elif retry: - return ( - jsonify({"error": "Failed to get auth"}), - HTTPStatus.FORBIDDEN, - ) - else: - return await api_get_jukebox_song( - juke_id, sp_playlist, retry=True - ) - return r, HTTPStatus.OK - for item in r.json()["items"]: - tracks.append( - { - "id": item["track"]["id"], - "name": item["track"]["name"], - "album": item["track"]["album"]["name"], - "artist": item["track"]["artists"][0]["name"], - "image": item["track"]["album"]["images"][0]["url"], - } - ) - except AssertionError: - something = None - return jsonify([track for track in tracks]) - - -async def api_get_token(juke_id): - try: - jukebox = await get_jukebox(juke_id) - except: - return ( - jsonify({"error": "No Jukebox"}), - HTTPStatus.FORBIDDEN, - ) - - async with httpx.AsyncClient() as client: - try: - r = await client.post( - "https://accounts.spotify.com/api/token", - timeout=40, - params={ - "grant_type": "refresh_token", - "refresh_token": jukebox.sp_refresh_token, - "client_id": jukebox.sp_user, - }, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": "Basic " - + base64.b64encode( - str(jukebox.sp_user + ":" + jukebox.sp_secret).encode("ascii") - ).decode("ascii"), - "Content-Type": "application/x-www-form-urlencoded", - }, - ) - if "access_token" not in r.json(): - return False - else: - await update_jukebox( - juke_id=juke_id, sp_access_token=r.json()["access_token"] - ) - except AssertionError: - something = None - return True - - -######CHECK DEVICE - - -@jukebox_ext.route("/api/v1/jukebox/jb/", methods=["GET"]) -async def api_get_jukebox_device_check(juke_id, retry=False): - try: - jukebox = await get_jukebox(juke_id) - except: - return ( - jsonify({"error": "No Jukebox"}), - HTTPStatus.FORBIDDEN, - ) - async with httpx.AsyncClient() as client: - rDevice = await client.get( - "https://api.spotify.com/v1/me/player/devices", - timeout=40, - headers={"Authorization": "Bearer " + jukebox.sp_access_token}, - ) - - if rDevice.status_code == 204 or rDevice.status_code == 200: - return ( - rDevice, - HTTPStatus.OK, - ) - elif rDevice.status_code == 401 or rDevice.status_code == 403: - token = await api_get_token(juke_id) - if token == False: - return ( - jsonify({"error": "No device connected"}), - HTTPStatus.FORBIDDEN, - ) - elif retry: - return ( - jsonify({"error": "Failed to get auth"}), - HTTPStatus.FORBIDDEN, - ) - else: - return api_get_jukebox_device_check(juke_id, retry=True) - else: - return ( - jsonify({"error": "No device connected"}), - HTTPStatus.FORBIDDEN, - ) - - -######GET INVOICE STUFF - - -@jukebox_ext.route("/api/v1/jukebox/jb/invoice//", methods=["GET"]) -async def api_get_jukebox_invoice(juke_id, song_id): - try: - jukebox = await get_jukebox(juke_id) - except: - return ( - jsonify({"error": "No Jukebox"}), - HTTPStatus.FORBIDDEN, - ) - try: - deviceCheck = await api_get_jukebox_device_check(juke_id) - devices = json.loads(deviceCheck[0].text) - deviceConnected = False - for device in devices["devices"]: - if device["id"] == jukebox.sp_device.split("-")[1]: - deviceConnected = True - if not deviceConnected: - return ( - jsonify({"error": "No device connected"}), - HTTPStatus.NOT_FOUND, - ) - except: - return ( - jsonify({"error": "No device connected"}), - HTTPStatus.NOT_FOUND, - ) - - invoice = await create_invoice( - wallet_id=jukebox.wallet, - amount=jukebox.price, - memo=jukebox.title, - extra={"tag": "jukebox"}, - ) - - jukebox_payment = await create_jukebox_payment(song_id, invoice[0], juke_id) - - return jsonify(invoice, jukebox_payment) - - -@jukebox_ext.route( - "/api/v1/jukebox/jb/checkinvoice//", methods=["GET"] -) -async def api_get_jukebox_invoice_check(pay_hash, juke_id): - try: - jukebox = await get_jukebox(juke_id) - except: - return ( - jsonify({"error": "No Jukebox"}), - HTTPStatus.FORBIDDEN, - ) - try: - status = await check_invoice_status(jukebox.wallet, pay_hash) - is_paid = not status.pending - except Exception as exc: - return jsonify({"paid": False}), HTTPStatus.OK - if is_paid: - wallet = await get_wallet(jukebox.wallet) - payment = await wallet.get_payment(pay_hash) - await payment.set_pending(False) - await update_jukebox_payment(pay_hash, paid=True) - return jsonify({"paid": True}), HTTPStatus.OK - return jsonify({"paid": False}), HTTPStatus.OK - - -@jukebox_ext.route( - "/api/v1/jukebox/jb/invoicep///", methods=["GET"] -) -async def api_get_jukebox_invoice_paid(song_id, juke_id, pay_hash, retry=False): - try: - jukebox = await get_jukebox(juke_id) - except: - return ( - jsonify({"error": "No Jukebox"}), - HTTPStatus.FORBIDDEN, - ) - await api_get_jukebox_invoice_check(pay_hash, juke_id) - jukebox_payment = await get_jukebox_payment(pay_hash) - if jukebox_payment.paid: - async with httpx.AsyncClient() as client: - r = await client.get( - "https://api.spotify.com/v1/me/player/currently-playing?market=ES", - timeout=40, - headers={"Authorization": "Bearer " + jukebox.sp_access_token}, - ) - rDevice = await client.get( - "https://api.spotify.com/v1/me/player", - timeout=40, - headers={"Authorization": "Bearer " + jukebox.sp_access_token}, - ) - isPlaying = False - if rDevice.status_code == 200: - isPlaying = rDevice.json()["is_playing"] - - if r.status_code == 204 or isPlaying == False: - async with httpx.AsyncClient() as client: - uri = ["spotify:track:" + song_id] - r = await client.put( - "https://api.spotify.com/v1/me/player/play?device_id=" - + jukebox.sp_device.split("-")[1], - json={"uris": uri}, - timeout=40, - headers={"Authorization": "Bearer " + jukebox.sp_access_token}, - ) - if r.status_code == 204: - return jsonify(jukebox_payment), HTTPStatus.OK - elif r.status_code == 401 or r.status_code == 403: - token = await api_get_token(juke_id) - if token == False: - return ( - jsonify({"error": "Invoice not paid"}), - HTTPStatus.FORBIDDEN, - ) - elif retry: - return ( - jsonify({"error": "Failed to get auth"}), - HTTPStatus.FORBIDDEN, - ) - else: - return api_get_jukebox_invoice_paid( - song_id, juke_id, pay_hash, retry=True - ) - else: - return ( - jsonify({"error": "Invoice not paid"}), - HTTPStatus.FORBIDDEN, - ) - elif r.status_code == 200: - async with httpx.AsyncClient() as client: - r = await client.post( - "https://api.spotify.com/v1/me/player/queue?uri=spotify%3Atrack%3A" - + song_id - + "&device_id=" - + jukebox.sp_device.split("-")[1], - timeout=40, - headers={"Authorization": "Bearer " + jukebox.sp_access_token}, - ) - if r.status_code == 204: - return jsonify(jukebox_payment), HTTPStatus.OK - - elif r.status_code == 401 or r.status_code == 403: - token = await api_get_token(juke_id) - if token == False: - return ( - jsonify({"error": "Invoice not paid"}), - HTTPStatus.OK, - ) - elif retry: - return ( - jsonify({"error": "Failed to get auth"}), - HTTPStatus.FORBIDDEN, - ) - else: - return await api_get_jukebox_invoice_paid( - song_id, juke_id, pay_hash - ) - else: - return ( - jsonify({"error": "Invoice not paid"}), - HTTPStatus.OK, - ) - elif r.status_code == 401 or r.status_code == 403: - token = await api_get_token(juke_id) - if token == False: - return ( - jsonify({"error": "Invoice not paid"}), - HTTPStatus.OK, - ) - elif retry: - return ( - jsonify({"error": "Failed to get auth"}), - HTTPStatus.FORBIDDEN, - ) - else: - return await api_get_jukebox_invoice_paid( - song_id, juke_id, pay_hash - ) - return jsonify({"error": "Invoice not paid"}), HTTPStatus.OK - - -############################GET TRACKS - - -@jukebox_ext.route("/api/v1/jukebox/jb/currently/", methods=["GET"]) -async def api_get_jukebox_currently(juke_id, retry=False): - try: - jukebox = await get_jukebox(juke_id) - except: - return ( - jsonify({"error": "No Jukebox"}), - HTTPStatus.FORBIDDEN, - ) - async with httpx.AsyncClient() as client: - try: - r = await client.get( - "https://api.spotify.com/v1/me/player/currently-playing?market=ES", - timeout=40, - headers={"Authorization": "Bearer " + jukebox.sp_access_token}, - ) - if r.status_code == 204: - return jsonify({"error": "Nothing"}), HTTPStatus.OK - elif r.status_code == 200: - try: - response = r.json() - - track = { - "id": response["item"]["id"], - "name": response["item"]["name"], - "album": response["item"]["album"]["name"], - "artist": response["item"]["artists"][0]["name"], - "image": response["item"]["album"]["images"][0]["url"], - } - return jsonify(track), HTTPStatus.OK - except: - return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND - - elif r.status_code == 401: - token = await api_get_token(juke_id) - if token == False: - return ( - jsonify({"error": "Invoice not paid"}), - HTTPStatus.FORBIDDEN, - ) - elif retry: - return ( - jsonify({"error": "Failed to get auth"}), - HTTPStatus.FORBIDDEN, - ) - else: - return await api_get_jukebox_currently(juke_id, retry=True) - else: - return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND - except AssertionError: - return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND diff --git a/lnbits/extensions/livestream/README.md b/lnbits/extensions/livestream/README.md deleted file mode 100644 index 4e88e7bc..00000000 --- a/lnbits/extensions/livestream/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# DJ Livestream - -## Help DJ's and music producers conduct music livestreams - -LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet. - -When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional). - -The revenue will be sent to a wallet created specifically for that producer, with optional revenue splitting between the DJ and the producer. - -[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) - -[![video tutorial livestream](http://img.youtube.com/vi/zDrSWShKz7k/0.jpg)](https://youtu.be/zDrSWShKz7k 'video tutorial offline shop') - -## Usage - -1. Start by adding a track\ - ![add new track](https://i.imgur.com/Cu0eGrW.jpg) - - set the producer, or choose an existing one - - set the track name - - define a minimum price where a user can download the track - - set the download URL, where user will be redirected if he tips the livestream and the tip is equal or above the set price\ - ![track settings](https://i.imgur.com/HTJYwcW.jpg) -2. Adjust the percentage of the pay you want to take from the user's tips. 10%, the default, means that the DJ will keep 10% of all the tips sent by users. The other 90% will go to an auto generated producer wallet\ - ![adjust percentage](https://i.imgur.com/9weHKAB.jpg) -3. For every different producer added, when adding tracks, a wallet is generated for them\ - ![producer wallet](https://i.imgur.com/YFIZ7Tm.jpg) -4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed -5. After all tracks and producers are added, you can start "playing" songs\ - ![play tracks](https://i.imgur.com/7ytiBkq.jpg) -6. You'll see the current track playing and a green icon indicating active track also\ - ![active track](https://i.imgur.com/W1vBz54.jpg) -7. When a user scans the QR code, and sends a tip, you'll receive 10%, in the example case, in your wallet and the producer's wallet will get the rest. For example someone tips 100 sats, you'll get 10 sats and the producer will get 90 sats - - producer's wallet receiving 18 sats from 20 sats tips\ - ![producer wallet](https://i.imgur.com/OM9LawA.jpg) - -## Use cases - -You can print the QR code and display it on a live gig, a street performance, etc... OR you can use the QR as an overlay in an online stream of you playing music, doing a DJ set, making a podcast. - -You can use the extension's API to trigger updates for the current track, update fees, add tracks... - -## Sponsored by - -[![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/) diff --git a/lnbits/extensions/livestream/__init__.py b/lnbits/extensions/livestream/__init__.py deleted file mode 100644 index d8f61fe0..00000000 --- a/lnbits/extensions/livestream/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from quart import Blueprint - -from lnbits.db import Database - -db = Database("ext_livestream") - -livestream_ext: Blueprint = Blueprint( - "livestream", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa -from .lnurl import * # noqa -from .tasks import register_listeners - -from lnbits.tasks import record_async - -livestream_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/livestream/config.json b/lnbits/extensions/livestream/config.json deleted file mode 100644 index 12ba6b79..00000000 --- a/lnbits/extensions/livestream/config.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "DJ Livestream", - "short_description": "Sell tracks and split revenue (lnurl-pay)", - "icon": "speaker", - "contributors": [ - "fiatjaf", - "cryptograffiti" - ], - "hidden": false -} diff --git a/lnbits/extensions/livestream/crud.py b/lnbits/extensions/livestream/crud.py deleted file mode 100644 index 47854dbd..00000000 --- a/lnbits/extensions/livestream/crud.py +++ /dev/null @@ -1,199 +0,0 @@ -from typing import List, Optional - -from lnbits.core.crud import create_account, create_wallet -from lnbits.db import SQLITE -from . import db -from .models import Livestream, Track, Producer - - -async def create_livestream(*, wallet_id: str) -> int: - returning = "" if db.type == SQLITE else "RETURNING ID" - method = db.execute if db.type == SQLITE else db.fetchone - - result = await (method)( - f""" - INSERT INTO livestream.livestreams (wallet) - VALUES (?) - {returning} - """, - (wallet_id,), - ) - - if db.type == SQLITE: - return result._result_proxy.lastrowid - else: - return result[0] - - -async def get_livestream(ls_id: int) -> Optional[Livestream]: - row = await db.fetchone( - "SELECT * FROM livestream.livestreams WHERE id = ?", (ls_id,) - ) - return Livestream(**dict(row)) if row else None - - -async def get_livestream_by_track(track_id: int) -> Optional[Livestream]: - row = await db.fetchone( - """ - SELECT livestreams.* FROM livestream.livestreams - INNER JOIN tracks ON tracks.livestream = livestreams.id - WHERE tracks.id = ? - """, - (track_id,), - ) - return Livestream(**dict(row)) if row else None - - -async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]: - row = await db.fetchone( - "SELECT * FROM livestream.livestreams WHERE wallet = ?", (wallet,) - ) - - if not row: - # create on the fly - ls_id = await create_livestream(wallet_id=wallet) - return await get_livestream(ls_id) - - return Livestream(**dict(row)) if row else None - - -async def update_current_track(ls_id: int, track_id: Optional[int]): - await db.execute( - "UPDATE livestream.livestreams SET current_track = ? WHERE id = ?", - (track_id, ls_id), - ) - - -async def update_livestream_fee(ls_id: int, fee_pct: int): - await db.execute( - "UPDATE livestream.livestreams SET fee_pct = ? WHERE id = ?", - (fee_pct, ls_id), - ) - - -async def add_track( - livestream: int, - name: str, - download_url: Optional[str], - price_msat: int, - producer: Optional[int], -) -> int: - result = await db.execute( - """ - INSERT INTO livestream.tracks (livestream, name, download_url, price_msat, producer) - VALUES (?, ?, ?, ?, ?) - """, - (livestream, name, download_url, price_msat, producer), - ) - return result._result_proxy.lastrowid - - -async def update_track( - livestream: int, - track_id: int, - name: str, - download_url: Optional[str], - price_msat: int, - producer: int, -) -> int: - result = await db.execute( - """ - UPDATE livestream.tracks SET - name = ?, - download_url = ?, - price_msat = ?, - producer = ? - WHERE livestream = ? AND id = ? - """, - (name, download_url, price_msat, producer, livestream, track_id), - ) - return result._result_proxy.lastrowid - - -async def get_track(track_id: Optional[int]) -> Optional[Track]: - if not track_id: - return None - - row = await db.fetchone( - """ - SELECT id, download_url, price_msat, name, producer - FROM livestream.tracks WHERE id = ? - """, - (track_id,), - ) - return Track(**dict(row)) if row else None - - -async def get_tracks(livestream: int) -> List[Track]: - rows = await db.fetchall( - """ - SELECT id, download_url, price_msat, name, producer - FROM livestream.tracks WHERE livestream = ? - """, - (livestream,), - ) - return [Track(**dict(row)) for row in rows] - - -async def delete_track_from_livestream(livestream: int, track_id: int): - await db.execute( - """ - DELETE FROM livestream.tracks WHERE livestream = ? AND id = ? - """, - (livestream, track_id), - ) - - -async def add_producer(livestream: int, name: str) -> int: - name = name.strip() - - existing = await db.fetchall( - """ - SELECT id FROM livestream.producers - WHERE livestream = ? AND lower(name) = ? - """, - (livestream, name.lower()), - ) - if existing: - return existing[0].id - - user = await create_account() - wallet = await create_wallet(user_id=user.id, wallet_name="livestream: " + name) - - returning = "" if db.type == SQLITE else "RETURNING ID" - method = db.execute if db.type == SQLITE else db.fetchone - - result = await method( - f""" - INSERT INTO livestream.producers (livestream, name, "user", wallet) - VALUES (?, ?, ?, ?) - {returning} - """, - (livestream, name, user.id, wallet.id), - ) - if db.type == SQLITE: - return result._result_proxy.lastrowid - else: - return result[0] - - -async def get_producer(producer_id: int) -> Optional[Producer]: - row = await db.fetchone( - """ - SELECT id, "user", wallet, name - FROM livestream.producers WHERE id = ? - """, - (producer_id,), - ) - return Producer(**dict(row)) if row else None - - -async def get_producers(livestream: int) -> List[Producer]: - rows = await db.fetchall( - """ - SELECT id, "user", wallet, name - FROM livestream.producers WHERE livestream = ? - """, - (livestream,), - ) - return [Producer(**dict(row)) for row in rows] diff --git a/lnbits/extensions/livestream/lnurl.py b/lnbits/extensions/livestream/lnurl.py deleted file mode 100644 index 3b9e7e31..00000000 --- a/lnbits/extensions/livestream/lnurl.py +++ /dev/null @@ -1,114 +0,0 @@ -import hashlib -import math -from quart import jsonify, url_for, request -from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore - -from lnbits.core.services import create_invoice - -from . import livestream_ext -from .crud import get_livestream, get_livestream_by_track, get_track - - -@livestream_ext.route("/lnurl/", methods=["GET"]) -async def lnurl_livestream(ls_id): - ls = await get_livestream(ls_id) - if not ls: - return jsonify({"status": "ERROR", "reason": "Livestream not found."}) - - track = await get_track(ls.current_track) - if not track: - return jsonify({"status": "ERROR", "reason": "This livestream is offline."}) - - resp = LnurlPayResponse( - callback=url_for( - "livestream.lnurl_callback", track_id=track.id, _external=True - ), - min_sendable=track.min_sendable, - max_sendable=track.max_sendable, - metadata=await track.lnurlpay_metadata(), - ) - - params = resp.dict() - params["commentAllowed"] = 300 - - return jsonify(params) - - -@livestream_ext.route("/lnurl/t/", methods=["GET"]) -async def lnurl_track(track_id): - track = await get_track(track_id) - if not track: - return jsonify({"status": "ERROR", "reason": "Track not found."}) - - resp = LnurlPayResponse( - callback=url_for( - "livestream.lnurl_callback", track_id=track.id, _external=True - ), - min_sendable=track.min_sendable, - max_sendable=track.max_sendable, - metadata=await track.lnurlpay_metadata(), - ) - - params = resp.dict() - params["commentAllowed"] = 300 - - return jsonify(params) - - -@livestream_ext.route("/lnurl/cb/", methods=["GET"]) -async def lnurl_callback(track_id): - track = await get_track(track_id) - if not track: - return jsonify({"status": "ERROR", "reason": "Couldn't find track."}) - - amount_received = int(request.args.get("amount") or 0) - - if amount_received < track.min_sendable: - return ( - jsonify( - LnurlErrorResponse( - reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}." - ).dict() - ), - ) - elif track.max_sendable < amount_received: - return ( - jsonify( - LnurlErrorResponse( - reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}." - ).dict() - ), - ) - - comment = request.args.get("comment") - if len(comment or "") > 300: - return jsonify( - LnurlErrorResponse( - reason=f"Got a comment with {len(comment)} characters, but can only accept 300" - ).dict() - ) - - ls = await get_livestream_by_track(track_id) - - payment_hash, payment_request = await create_invoice( - wallet_id=ls.wallet, - amount=int(amount_received / 1000), - memo=await track.fullname(), - description_hash=hashlib.sha256( - (await track.lnurlpay_metadata()).encode("utf-8") - ).digest(), - extra={"tag": "livestream", "track": track.id, "comment": comment}, - ) - - if amount_received < track.price_msat: - success_action = None - else: - success_action = track.success_action(payment_hash) - - resp = LnurlPayActionResponse( - pr=payment_request, - success_action=success_action, - routes=[], - ) - - return jsonify(resp.dict()) diff --git a/lnbits/extensions/livestream/migrations.py b/lnbits/extensions/livestream/migrations.py deleted file mode 100644 index fb664ab1..00000000 --- a/lnbits/extensions/livestream/migrations.py +++ /dev/null @@ -1,39 +0,0 @@ -async def m001_initial(db): - """ - Initial livestream tables. - """ - await db.execute( - f""" - CREATE TABLE livestream.livestreams ( - id {db.serial_primary_key}, - wallet TEXT NOT NULL, - fee_pct INTEGER NOT NULL DEFAULT 10, - current_track INTEGER - ); - """ - ) - - await db.execute( - f""" - CREATE TABLE livestream.producers ( - livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id), - id {db.serial_primary_key}, - "user" TEXT NOT NULL, - wallet TEXT NOT NULL, - name TEXT NOT NULL - ); - """ - ) - - await db.execute( - f""" - CREATE TABLE livestream.tracks ( - livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id), - id {db.serial_primary_key}, - download_url TEXT, - price_msat INTEGER NOT NULL DEFAULT 0, - name TEXT, - producer INTEGER REFERENCES {db.references_schema}producers (id) NOT NULL - ); - """ - ) diff --git a/lnbits/extensions/livestream/models.py b/lnbits/extensions/livestream/models.py deleted file mode 100644 index bfe82973..00000000 --- a/lnbits/extensions/livestream/models.py +++ /dev/null @@ -1,81 +0,0 @@ -import json -from quart import url_for -from typing import NamedTuple, Optional -from lnurl import Lnurl, encode as lnurl_encode # type: ignore -from lnurl.types import LnurlPayMetadata # type: ignore -from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore - - -class Livestream(NamedTuple): - id: int - wallet: str - fee_pct: int - current_track: Optional[int] - - @property - def lnurl(self) -> Lnurl: - url = url_for("livestream.lnurl_livestream", ls_id=self.id, _external=True) - return lnurl_encode(url) - - -class Track(NamedTuple): - id: int - download_url: str - price_msat: int - name: str - producer: int - - @property - def min_sendable(self) -> int: - return min(100_000, self.price_msat or 100_000) - - @property - def max_sendable(self) -> int: - return max(50_000_000, self.price_msat * 5) - - @property - def lnurl(self) -> Lnurl: - url = url_for("livestream.lnurl_track", track_id=self.id, _external=True) - return lnurl_encode(url) - - async def fullname(self) -> str: - from .crud import get_producer - - producer = await get_producer(self.producer) - if producer: - producer_name = producer.name - else: - producer_name = "unknown author" - - return f"'{self.name}', from {producer_name}." - - async def lnurlpay_metadata(self) -> LnurlPayMetadata: - description = ( - await self.fullname() - ) + " Like this track? Send some sats in appreciation." - - if self.download_url: - description += f" Send {round(self.price_msat/1000)} sats or more and you can download it." - - return LnurlPayMetadata(json.dumps([["text/plain", description]])) - - def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]: - if not self.download_url: - return None - - return UrlAction( - url=url_for( - "livestream.track_redirect_download", - track_id=self.id, - p=payment_hash, - _external=True, - ), - description=f"Download the track {self.name}!", - ) - - -class Producer(NamedTuple): - id: int - user: str - wallet: str - name: str diff --git a/lnbits/extensions/livestream/static/js/index.js b/lnbits/extensions/livestream/static/js/index.js deleted file mode 100644 index c49befce..00000000 --- a/lnbits/extensions/livestream/static/js/index.js +++ /dev/null @@ -1,216 +0,0 @@ -/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ - -Vue.component(VueQrcode.name, VueQrcode) - -new Vue({ - el: '#vue', - mixins: [windowMixin], - data() { - return { - cancelListener: () => {}, - selectedWallet: null, - nextCurrentTrack: null, - livestream: { - tracks: [], - producers: [] - }, - trackDialog: { - show: false, - data: {} - } - } - }, - computed: { - sortedTracks() { - return this.livestream.tracks.sort((a, b) => a.name - b.name) - }, - tracksMap() { - return Object.fromEntries( - this.livestream.tracks.map(track => [track.id, track]) - ) - }, - producersMap() { - return Object.fromEntries( - this.livestream.producers.map(prod => [prod.id, prod]) - ) - } - }, - methods: { - getTrackLabel(trackId) { - if (!trackId) return - let track = this.tracksMap[trackId] - return `${track.name}, ${this.producersMap[track.producer].name}` - }, - disabledAddTrackButton() { - return ( - !this.trackDialog.data.name || - this.trackDialog.data.name.length === 0 || - !this.trackDialog.data.producer || - this.trackDialog.data.producer.length === 0 - ) - }, - changedWallet(wallet) { - this.selectedWallet = wallet - this.loadLivestream() - this.startPaymentNotifier() - }, - loadLivestream() { - LNbits.api - .request( - 'GET', - '/livestream/api/v1/livestream', - this.selectedWallet.inkey - ) - .then(response => { - this.livestream = response.data - this.nextCurrentTrack = this.livestream.current_track - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - startPaymentNotifier() { - this.cancelListener() - - this.cancelListener = LNbits.events.onInvoicePaid( - this.selectedWallet, - payment => { - let satoshiAmount = Math.round(payment.amount / 1000) - let trackName = ( - this.tracksMap[payment.extra.track] || {name: '[unknown]'} - ).name - - this.$q.notify({ - message: `Someone paid ${satoshiAmount} sat for the track ${trackName}.`, - caption: payment.extra.comment - ? `"${payment.extra.comment}"` - : undefined, - color: 'secondary', - html: true, - timeout: 0, - actions: [{label: 'Dismiss', color: 'white', handler: () => {}}] - }) - } - ) - }, - addTrack() { - let {id, name, producer, price_sat, download_url} = this.trackDialog.data - - const [method, path] = id - ? ['PUT', `/livestream/api/v1/livestream/tracks/${id}`] - : ['POST', '/livestream/api/v1/livestream/tracks'] - - LNbits.api - .request(method, path, this.selectedWallet.inkey, { - download_url: - download_url && download_url.length > 0 ? download_url : undefined, - name, - price_msat: price_sat * 1000 || 0, - producer_name: typeof producer === 'string' ? producer : undefined, - producer_id: typeof producer === 'object' ? producer.id : undefined - }) - .then(response => { - this.$q.notify({ - message: `Track '${this.trackDialog.data.name}' added.`, - timeout: 700 - }) - this.loadLivestream() - this.trackDialog.show = false - this.trackDialog.data = {} - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - openAddTrackDialog() { - this.trackDialog.show = true - this.trackDialog.data = {} - }, - openUpdateDialog(itemId) { - this.trackDialog.show = true - let item = this.livestream.tracks.find(item => item.id === itemId) - this.trackDialog.data = { - ...item, - producer: this.livestream.producers.find( - prod => prod.id === item.producer - ), - price_sat: Math.round(item.price_msat / 1000) - } - }, - deleteTrack(trackId) { - LNbits.utils - .confirmDialog('Are you sure you want to delete this track?') - .onOk(() => { - LNbits.api - .request( - 'DELETE', - '/livestream/api/v1/livestream/tracks/' + trackId, - this.selectedWallet.inkey - ) - .then(response => { - this.$q.notify({ - message: `Track deleted`, - timeout: 700 - }) - this.livestream.tracks.splice( - this.livestream.tracks.findIndex(track => track.id === trackId), - 1 - ) - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }) - }, - updateCurrentTrack(track) { - console.log(this.nextCurrentTrack, this.livestream) - if (this.livestream.current_track === track) { - // if clicking the same, stop it - track = 0 - } - - LNbits.api - .request( - 'PUT', - '/livestream/api/v1/livestream/track/' + track, - this.selectedWallet.inkey - ) - .then(() => { - this.livestream.current_track = track - this.nextCurrentTrack = track - this.$q.notify({ - message: `Current track updated.`, - timeout: 700 - }) - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - updateFeePct() { - LNbits.api - .request( - 'PUT', - '/livestream/api/v1/livestream/fee/' + this.livestream.fee_pct, - this.selectedWallet.inkey - ) - .then(() => { - this.$q.notify({ - message: `Percentage updated.`, - timeout: 700 - }) - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - producerAdded(added, cb) { - cb(added) - } - }, - created() { - this.selectedWallet = this.g.user.wallets[0] - this.loadLivestream() - this.startPaymentNotifier() - } -}) diff --git a/lnbits/extensions/livestream/tasks.py b/lnbits/extensions/livestream/tasks.py deleted file mode 100644 index 52f86d15..00000000 --- a/lnbits/extensions/livestream/tasks.py +++ /dev/null @@ -1,89 +0,0 @@ -import json -import trio - -from lnbits.core.models import Payment -from lnbits.core.crud import create_payment -from lnbits.core import db as core_db -from lnbits.tasks import register_invoice_listener, internal_invoice_paid -from lnbits.helpers import urlsafe_short_hash - -from .crud import get_track, get_producer, get_livestream_by_track - - -async def register_listeners(): - invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) - register_invoice_listener(invoice_paid_chan_send) - await wait_for_paid_invoices(invoice_paid_chan_recv) - - -async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): - async for payment in invoice_paid_chan: - await on_invoice_paid(payment) - - -async def on_invoice_paid(payment: Payment) -> None: - if "livestream" != payment.extra.get("tag"): - # not a livestream invoice - return - - track = await get_track(payment.extra.get("track", -1)) - if not track: - print("this should never happen", payment) - return - - if payment.extra.get("shared_with"): - print("payment was shared already", payment) - return - - producer = await get_producer(track.producer) - assert producer, f"track {track.id} is not associated with a producer" - - ls = await get_livestream_by_track(track.id) - assert ls, f"track {track.id} is not associated with a livestream" - - # now we make a special kind of internal transfer - amount = int(payment.amount * (100 - ls.fee_pct) / 100) - - # mark the original payment with two extra keys, "shared_with" and "received" - # (this prevents us from doing this process again and it's informative) - # and reduce it by the amount we're going to send to the producer - await core_db.execute( - """ - UPDATE apipayments - SET extra = ?, amount = ? - WHERE hash = ? - AND checking_id NOT LIKE 'internal_%' - """, - ( - json.dumps( - dict( - **payment.extra, - shared_with=[producer.name, producer.id], - received=payment.amount, - ) - ), - payment.amount - amount, - payment.payment_hash, - ), - ) - - # perform an internal transfer using the same payment_hash to the producer wallet - internal_checking_id = f"internal_{urlsafe_short_hash()}" - await create_payment( - wallet_id=producer.wallet, - checking_id=internal_checking_id, - payment_request="", - payment_hash=payment.payment_hash, - amount=amount, - memo=f"Revenue from '{track.name}'.", - pending=False, - ) - - # manually send this for now - await internal_invoice_paid.send(internal_checking_id) - - # so the flow is the following: - # - we receive, say, 1000 satoshis - # - if the fee_pct is, say, 30%, the amount we will send is 700 - # - we change the amount of receiving payment on the database from 1000 to 300 - # - we create a new payment on the producer's wallet with amount 700 diff --git a/lnbits/extensions/livestream/templates/livestream/_api_docs.html b/lnbits/extensions/livestream/templates/livestream/_api_docs.html deleted file mode 100644 index fd92f0f3..00000000 --- a/lnbits/extensions/livestream/templates/livestream/_api_docs.html +++ /dev/null @@ -1,146 +0,0 @@ - - - -

Add tracks, profit.

-
-
-
- - - - - - GET - /livestream/api/v1/livestream -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [<livestream_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - PUT - /livestream/api/v1/livestream/track/<track_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 201 CREATED (application/json) -
-
Curl example
- curl -X PUT {{ request.url_root - }}api/v1/livestream/track/<track_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - PUT - /livestream/api/v1/livestream/fee/<fee_pct> -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 201 CREATED (application/json) -
-
Curl example
- curl -X PUT {{ request.url_root - }}api/v1/livestream/fee/<fee_pct> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - - POST - /livestream/api/v1/livestream/tracks -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
- {"name": <string>, "download_url": <string>, - "price_msat": <integer>, "producer_id": <integer>, - "producer_name": <string>} -
- Returns 201 CREATED (application/json) -
-
Curl example
- curl -X POST {{ request.url_root }}api/v1/livestream/tracks -d - '{"name": <string>, "download_url": <string>, - "price_msat": <integer>, "producer_id": <integer>, - "producer_name": <string>}' -H "Content-type: application/json" - -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" - -
-
-
- - - - DELETE - /livestream/api/v1/livestream/tracks/<track_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.url_root - }}api/v1/livestream/tracks/<track_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
-
diff --git a/lnbits/extensions/livestream/templates/livestream/index.html b/lnbits/extensions/livestream/templates/livestream/index.html deleted file mode 100644 index a93bab71..00000000 --- a/lnbits/extensions/livestream/templates/livestream/index.html +++ /dev/null @@ -1,322 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - -
-
- -
-
- {% raw %} - - {{ nextCurrentTrack && nextCurrentTrack === - livestream.current_track ? 'Stop' : 'Set' }} current track - - {% endraw %} -
-
-
- -
-
- -
-
- Set percent rate -
-
-
-
- - - -
-
-
Tracks
-
-
- Add new track -
-
- - {% raw %} - - - {% endraw %} - -
-
- - - -
-
-
Producers
-
-
- - {% raw %} - - - {% endraw %} - -
-
- - - - - - - - - - - - - - - Copy LNURL-pay code - - -
- -
- - -
- {{SITE_TITLE}} Livestream extension -
-
- - - {% include "livestream/_api_docs.html" %} - -
-
- - - - -

- Standalone QR Code for this track -

- - - - - - - Copy LNURL-pay code -
- - - - - - - -
-
- - Update track - Add track - -
-
- Cancel -
-
-
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/livestream/views.py b/lnbits/extensions/livestream/views.py deleted file mode 100644 index 8864ac2c..00000000 --- a/lnbits/extensions/livestream/views.py +++ /dev/null @@ -1,38 +0,0 @@ -from quart import g, render_template, request, redirect -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids -from lnbits.core.models import Payment -from lnbits.core.crud import get_wallet_payment - -from . import livestream_ext -from .crud import get_track, get_livestream_by_track - - -@livestream_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("livestream/index.html", user=g.user) - - -@livestream_ext.route("/track/") -async def track_redirect_download(track_id): - payment_hash = request.args.get("p") - track = await get_track(track_id) - ls = await get_livestream_by_track(track_id) - payment: Payment = await get_wallet_payment(ls.wallet, payment_hash) - - if not payment: - return ( - f"Couldn't find the payment {payment_hash} or track {track.id}.", - HTTPStatus.NOT_FOUND, - ) - - if payment.pending: - return ( - f"Payment {payment_hash} wasn't received yet. Please try again in a minute.", - HTTPStatus.PAYMENT_REQUIRED, - ) - - return redirect(track.download_url) diff --git a/lnbits/extensions/livestream/views_api.py b/lnbits/extensions/livestream/views_api.py deleted file mode 100644 index c8816ac1..00000000 --- a/lnbits/extensions/livestream/views_api.py +++ /dev/null @@ -1,135 +0,0 @@ -from quart import g, jsonify -from http import HTTPStatus -from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore - -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import livestream_ext -from .crud import ( - get_or_create_livestream_by_wallet, - add_track, - get_tracks, - update_track, - add_producer, - get_producers, - update_current_track, - update_livestream_fee, - delete_track_from_livestream, -) - - -@livestream_ext.route("/api/v1/livestream", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_livestream_from_wallet(): - ls = await get_or_create_livestream_by_wallet(g.wallet.id) - tracks = await get_tracks(ls.id) - producers = await get_producers(ls.id) - - try: - return ( - jsonify( - { - **ls._asdict(), - **{ - "lnurl": ls.lnurl, - "tracks": [ - dict(lnurl=track.lnurl, **track._asdict()) - for track in tracks - ], - "producers": [producer._asdict() for producer in producers], - }, - } - ), - HTTPStatus.OK, - ) - except LnurlInvalidUrl: - return ( - jsonify( - { - "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." - } - ), - HTTPStatus.UPGRADE_REQUIRED, - ) - - -@livestream_ext.route("/api/v1/livestream/track/", methods=["PUT"]) -@api_check_wallet_key("invoice") -async def api_update_track(track_id): - try: - id = int(track_id) - except ValueError: - id = 0 - if id <= 0: - id = None - - ls = await get_or_create_livestream_by_wallet(g.wallet.id) - await update_current_track(ls.id, id) - return "", HTTPStatus.NO_CONTENT - - -@livestream_ext.route("/api/v1/livestream/fee/", methods=["PUT"]) -@api_check_wallet_key("invoice") -async def api_update_fee(fee_pct): - ls = await get_or_create_livestream_by_wallet(g.wallet.id) - await update_livestream_fee(ls.id, int(fee_pct)) - return "", HTTPStatus.NO_CONTENT - - -@livestream_ext.route("/api/v1/livestream/tracks", methods=["POST"]) -@livestream_ext.route("/api/v1/livestream/tracks/", methods=["PUT"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "name": {"type": "string", "empty": False, "required": True}, - "download_url": {"type": "string", "empty": False, "required": False}, - "price_msat": {"type": "number", "min": 0, "required": False}, - "producer_id": { - "type": "number", - "required": True, - "excludes": "producer_name", - }, - "producer_name": { - "type": "string", - "required": True, - "excludes": "producer_id", - }, - } -) -async def api_add_track(id=None): - ls = await get_or_create_livestream_by_wallet(g.wallet.id) - - if "producer_id" in g.data: - p_id = g.data["producer_id"] - elif "producer_name" in g.data: - p_id = await add_producer(ls.id, g.data["producer_name"]) - else: - raise TypeError("need either producer_id or producer_name arguments") - - if id: - await update_track( - ls.id, - id, - g.data["name"], - g.data.get("download_url"), - g.data.get("price_msat", 0), - p_id, - ) - return "", HTTPStatus.OK - else: - await add_track( - ls.id, - g.data["name"], - g.data.get("download_url"), - g.data.get("price_msat", 0), - p_id, - ) - return "", HTTPStatus.CREATED - - -@livestream_ext.route("/api/v1/livestream/tracks/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_delete_track(track_id): - ls = await get_or_create_livestream_by_wallet(g.wallet.id) - await delete_track_from_livestream(ls.id, track_id) - return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/lndhub/README.md b/lnbits/extensions/lndhub/README.md deleted file mode 100644 index f567d549..00000000 --- a/lnbits/extensions/lndhub/README.md +++ /dev/null @@ -1,6 +0,0 @@ -

lndhub Extension

-

*connect to your lnbits wallet from BlueWallet or Zeus*

- -Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/. - -Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club. diff --git a/lnbits/extensions/lndhub/__init__.py b/lnbits/extensions/lndhub/__init__.py deleted file mode 100644 index 7610b0a3..00000000 --- a/lnbits/extensions/lndhub/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_lndhub") - -lndhub_ext: Blueprint = Blueprint( - "lndhub", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/lndhub/config.json b/lnbits/extensions/lndhub/config.json deleted file mode 100644 index 6285ff80..00000000 --- a/lnbits/extensions/lndhub/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "LndHub", - "short_description": "Access lnbits from BlueWallet or Zeus", - "icon": "navigation", - "contributors": ["fiatjaf"] -} diff --git a/lnbits/extensions/lndhub/decorators.py b/lnbits/extensions/lndhub/decorators.py deleted file mode 100644 index c9c3bb71..00000000 --- a/lnbits/extensions/lndhub/decorators.py +++ /dev/null @@ -1,29 +0,0 @@ -from base64 import b64decode -from quart import jsonify, g, request -from functools import wraps - -from lnbits.core.crud import get_wallet_for_key - - -def check_wallet(requires_admin=False): - def wrap(view): - @wraps(view) - async def wrapped_view(**kwargs): - token = request.headers["Authorization"].split("Bearer ")[1] - key_type, key = b64decode(token).decode("utf-8").split(":") - - if requires_admin and key_type != "admin": - return jsonify( - {"error": True, "code": 2, "message": "insufficient permissions"} - ) - - g.wallet = await get_wallet_for_key(key, key_type) - if not g.wallet: - return jsonify( - {"error": True, "code": 2, "message": "insufficient permissions"} - ) - return await view(**kwargs) - - return wrapped_view - - return wrap diff --git a/lnbits/extensions/lndhub/migrations.py b/lnbits/extensions/lndhub/migrations.py deleted file mode 100644 index d6ea5fde..00000000 --- a/lnbits/extensions/lndhub/migrations.py +++ /dev/null @@ -1,2 +0,0 @@ -async def migrate(): - pass diff --git a/lnbits/extensions/lndhub/templates/lndhub/_instructions.html b/lnbits/extensions/lndhub/templates/lndhub/_instructions.html deleted file mode 100644 index 4db79aba..00000000 --- a/lnbits/extensions/lndhub/templates/lndhub/_instructions.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - To access an LNbits wallet from a mobile phone, -
    -
  1. - Install either Zeus or - BlueWallet; -
  2. -
  3. - Go to Add a wallet / Import wallet on BlueWallet or - Settings / Add a new node on Zeus. -
  4. -
  5. Select the desired wallet on this page;
  6. -
  7. Scan one of the two QR codes from the mobile wallet.
  8. -
-
    -
  • - Invoice URLs mean the mobile wallet will only have the - authorization to read your payments and invoices and generate new - invoices. -
  • -
  • - Admin URLs mean the mobile wallet will be able to pay - invoices.. -
  • -
-
-
-
diff --git a/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html b/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html deleted file mode 100644 index a15cab8f..00000000 --- a/lnbits/extensions/lndhub/templates/lndhub/_lndhub.html +++ /dev/null @@ -1,19 +0,0 @@ - - - -

- LndHub is a protocol invented by - BlueWallet that allows mobile - wallets to query payments and balances, generate invoices and make - payments from accounts that exist on a server. The protocol is a - collection of HTTP endpoints exposed through the internet. -

-

- For a wallet that supports it, reading a QR code that contains the URL - along with secret access credentials should enable access. Currently it - is supported by Zeus and - BlueWallet. -

-
-
-
diff --git a/lnbits/extensions/lndhub/templates/lndhub/index.html b/lnbits/extensions/lndhub/templates/lndhub/index.html deleted file mode 100644 index ad0a3b04..00000000 --- a/lnbits/extensions/lndhub/templates/lndhub/index.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} {% raw %} -
-
-
- - - -
- Copy LndHub {{type}} URL -
-
-
-
- - - - - - - -
- - {% endraw %} - -
- - -
- {{SITE_TITLE}} LndHub extension -
-
- - - - {% include "lndhub/_instructions.html" %} - - {% include "lndhub/_lndhub.html" %} - - -
-
-
- -{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/lndhub/utils.py b/lnbits/extensions/lndhub/utils.py deleted file mode 100644 index 3db6317a..00000000 --- a/lnbits/extensions/lndhub/utils.py +++ /dev/null @@ -1,21 +0,0 @@ -from binascii import unhexlify - -from lnbits.bolt11 import Invoice - - -def to_buffer(payment_hash: str): - return {"type": "Buffer", "data": [b for b in unhexlify(payment_hash)]} - - -def decoded_as_lndhub(invoice: Invoice): - return { - "destination": invoice.payee, - "payment_hash": invoice.payment_hash, - "num_satoshis": invoice.amount_msat / 1000, - "timestamp": str(invoice.date), - "expiry": str(invoice.expiry), - "description": invoice.description, - "fallback_addr": "", - "cltv_expiry": invoice.min_final_cltv_expiry, - "route_hints": "", - } diff --git a/lnbits/extensions/lndhub/views.py b/lnbits/extensions/lndhub/views.py deleted file mode 100644 index 2bc01fc1..00000000 --- a/lnbits/extensions/lndhub/views.py +++ /dev/null @@ -1,11 +0,0 @@ -from quart import render_template, g - -from lnbits.decorators import check_user_exists, validate_uuids -from . import lndhub_ext - - -@lndhub_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def lndhub_index(): - return await render_template("lndhub/index.html", user=g.user) diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py deleted file mode 100644 index de61820a..00000000 --- a/lnbits/extensions/lndhub/views_api.py +++ /dev/null @@ -1,240 +0,0 @@ -import time -from base64 import urlsafe_b64encode -from quart import jsonify, g, request - -from lnbits.core.services import pay_invoice, create_invoice -from lnbits.core.crud import get_payments, delete_expired_invoices -from lnbits.decorators import api_validate_post_request -from lnbits.settings import WALLET -from lnbits import bolt11 - -from . import lndhub_ext -from .decorators import check_wallet -from .utils import to_buffer, decoded_as_lndhub - - -@lndhub_ext.route("/ext/getinfo", methods=["GET"]) -async def lndhub_getinfo(): - return jsonify({"error": True, "code": 1, "message": "bad auth"}) - - -@lndhub_ext.route("/ext/auth", methods=["POST"]) -@api_validate_post_request( - schema={ - "login": {"type": "string", "required": True, "excludes": "refresh_token"}, - "password": {"type": "string", "required": True, "excludes": "refresh_token"}, - "refresh_token": { - "type": "string", - "required": True, - "excludes": ["login", "password"], - }, - } -) -async def lndhub_auth(): - token = ( - g.data["refresh_token"] - if "refresh_token" in g.data and g.data["refresh_token"] - else urlsafe_b64encode( - (g.data["login"] + ":" + g.data["password"]).encode("utf-8") - ).decode("ascii") - ) - return jsonify({"refresh_token": token, "access_token": token}) - - -@lndhub_ext.route("/ext/addinvoice", methods=["POST"]) -@check_wallet() -@api_validate_post_request( - schema={ - "amt": {"type": "string", "required": True}, - "memo": {"type": "string", "required": True}, - "preimage": {"type": "string", "required": False}, - } -) -async def lndhub_addinvoice(): - try: - _, pr = await create_invoice( - wallet_id=g.wallet.id, - amount=int(g.data["amt"]), - memo=g.data["memo"], - extra={"tag": "lndhub"}, - ) - except Exception as e: - return jsonify( - { - "error": True, - "code": 7, - "message": "Failed to create invoice: " + str(e), - } - ) - - invoice = bolt11.decode(pr) - return jsonify( - { - "pay_req": pr, - "payment_request": pr, - "add_index": "500", - "r_hash": to_buffer(invoice.payment_hash), - "hash": invoice.payment_hash, - } - ) - - -@lndhub_ext.route("/ext/payinvoice", methods=["POST"]) -@check_wallet(requires_admin=True) -@api_validate_post_request(schema={"invoice": {"type": "string", "required": True}}) -async def lndhub_payinvoice(): - try: - await pay_invoice( - wallet_id=g.wallet.id, - payment_request=g.data["invoice"], - extra={"tag": "lndhub"}, - ) - except Exception as e: - return jsonify( - { - "error": True, - "code": 10, - "message": "Payment failed: " + str(e), - } - ) - - invoice: bolt11.Invoice = bolt11.decode(g.data["invoice"]) - return jsonify( - { - "payment_error": "", - "payment_preimage": "0" * 64, - "route": {}, - "payment_hash": invoice.payment_hash, - "decoded": decoded_as_lndhub(invoice), - "fee_msat": 0, - "type": "paid_invoice", - "fee": 0, - "value": invoice.amount_msat / 1000, - "timestamp": int(time.time()), - "memo": invoice.description, - } - ) - - -@lndhub_ext.route("/ext/balance", methods=["GET"]) -@check_wallet() -async def lndhub_balance(): - return jsonify({"BTC": {"AvailableBalance": g.wallet.balance}}) - - -@lndhub_ext.route("/ext/gettxs", methods=["GET"]) -@check_wallet() -async def lndhub_gettxs(): - for payment in await get_payments( - wallet_id=g.wallet.id, - complete=False, - pending=True, - outgoing=True, - incoming=False, - exclude_uncheckable=True, - ): - await payment.set_pending( - (await WALLET.get_payment_status(payment.checking_id)).pending - ) - - limit = int(request.args.get("limit", 200)) - return jsonify( - [ - { - "payment_preimage": payment.preimage, - "payment_hash": payment.payment_hash, - "fee_msat": payment.fee * 1000, - "type": "paid_invoice", - "fee": payment.fee, - "value": int(payment.amount / 1000), - "timestamp": payment.time, - "memo": payment.memo - if not payment.pending - else "Payment in transition", - } - for payment in reversed( - ( - await get_payments( - wallet_id=g.wallet.id, - pending=True, - complete=True, - outgoing=True, - incoming=False, - ) - )[:limit] - ) - ] - ) - - -@lndhub_ext.route("/ext/getuserinvoices", methods=["GET"]) -@check_wallet() -async def lndhub_getuserinvoices(): - await delete_expired_invoices() - for invoice in await get_payments( - wallet_id=g.wallet.id, - complete=False, - pending=True, - outgoing=False, - incoming=True, - exclude_uncheckable=True, - ): - await invoice.set_pending( - (await WALLET.get_invoice_status(invoice.checking_id)).pending - ) - - limit = int(request.args.get("limit", 200)) - return jsonify( - [ - { - "r_hash": to_buffer(invoice.payment_hash), - "payment_request": invoice.bolt11, - "add_index": "500", - "description": invoice.memo, - "payment_hash": invoice.payment_hash, - "ispaid": not invoice.pending, - "amt": int(invoice.amount / 1000), - "expire_time": int(time.time() + 1800), - "timestamp": invoice.time, - "type": "user_invoice", - } - for invoice in reversed( - ( - await get_payments( - wallet_id=g.wallet.id, - pending=True, - complete=True, - incoming=True, - outgoing=False, - ) - )[:limit] - ) - ] - ) - - -@lndhub_ext.route("/ext/getbtc", methods=["GET"]) -@check_wallet() -async def lndhub_getbtc(): - "load an address for incoming onchain btc" - return jsonify([]) - - -@lndhub_ext.route("/ext/getpending", methods=["GET"]) -@check_wallet() -async def lndhub_getpending(): - "pending onchain transactions" - return jsonify([]) - - -@lndhub_ext.route("/ext/decodeinvoice", methods=["GET"]) -async def lndhub_decodeinvoice(): - invoice = request.args.get("invoice") - inv = bolt11.decode(invoice) - return jsonify(decoded_as_lndhub(inv)) - - -@lndhub_ext.route("/ext/checkrouteinvoice", methods=["GET"]) -async def lndhub_checkrouteinvoice(): - "not implemented on canonical lndhub" - pass diff --git a/lnbits/extensions/lnticket/README.md b/lnbits/extensions/lnticket/README.md deleted file mode 100644 index bd071450..00000000 --- a/lnbits/extensions/lnticket/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Support Tickets - -## Get paid sats to answer questions - -Charge a per word amount for people to contact you. - -Possible applications include, paid support ticketing, PAYG language services, contact spam protection. - -1. Click "NEW FORM" to create a new contact form\ - ![new contact form](https://i.imgur.com/kZqWGPe.png) -2. Fill out the contact form - - set the wallet to use - - give your form a name - - set an optional webhook that will get called when the form receives a payment - - give it a small description - - set the amount you want to charge, per **word**, for people to contact you\ - ![form settings](https://i.imgur.com/AsXeVet.png) -3. Your new contact form will appear on the _Forms_ section. Note that you can create various forms with different rates per word, for different purposes\ - ![forms section](https://i.imgur.com/gg71HhM.png) -4. When a user wants to reach out to you, they will get to the contact form. They can fill out some information: - - a name - - an optional email if they want you to reply - - and the actual message - - at the bottom, a value in satoshis, will display how much it will cost them to send this message\ - ![user view of form](https://i.imgur.com/DWGJWQz.png) - - after submiting the Lightning Network invoice will pop up and after payment the message will be sent to you\ - ![contact form payment](https://i.imgur.com/7heGsiO.png) -5. Back in "Support ticket" extension you'll get the messages your fans, users, haters, etc, sent you on the _Tickets_ section\ - ![tickets](https://i.imgur.com/dGhJ6Ok.png) diff --git a/lnbits/extensions/lnticket/__init__.py b/lnbits/extensions/lnticket/__init__.py deleted file mode 100644 index cfdadc40..00000000 --- a/lnbits/extensions/lnticket/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_lnticket") - -lnticket_ext: Blueprint = Blueprint( - "lnticket", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa -from .tasks import register_listeners - -from lnbits.tasks import record_async - -lnticket_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/lnticket/config.json b/lnbits/extensions/lnticket/config.json deleted file mode 100644 index 99581b8f..00000000 --- a/lnbits/extensions/lnticket/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Support Tickets", - "short_description": "LN support ticket system", - "icon": "contact_support", - "contributors": ["benarc"] -} diff --git a/lnbits/extensions/lnticket/crud.py b/lnbits/extensions/lnticket/crud.py deleted file mode 100644 index 5c1f1e02..00000000 --- a/lnbits/extensions/lnticket/crud.py +++ /dev/null @@ -1,156 +0,0 @@ -from typing import List, Optional, Union - -from lnbits.helpers import urlsafe_short_hash - -from . import db -from .models import Tickets, Forms -import httpx - - -async def create_ticket( - payment_hash: str, - wallet: str, - form: str, - name: str, - email: str, - ltext: str, - sats: int, -) -> Tickets: - await db.execute( - """ - INSERT INTO lnticket.ticket (id, form, email, ltext, name, wallet, sats, paid) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - (payment_hash, form, email, ltext, name, wallet, sats, False), - ) - - ticket = await get_ticket(payment_hash) - assert ticket, "Newly created ticket couldn't be retrieved" - return ticket - - -async def set_ticket_paid(payment_hash: str) -> Tickets: - row = await db.fetchone( - "SELECT * FROM lnticket.ticket WHERE id = ?", (payment_hash,) - ) - if row[7] == False: - await db.execute( - """ - UPDATE lnticket.ticket - SET paid = true - WHERE id = ? - """, - (payment_hash,), - ) - - formdata = await get_form(row[1]) - assert formdata, "Couldn't get form from paid ticket" - - amount = formdata.amountmade + row[7] - await db.execute( - """ - UPDATE lnticket.form2 - SET amountmade = ? - WHERE id = ? - """, - (amount, row[1]), - ) - - ticket = await get_ticket(payment_hash) - assert ticket, "Newly paid ticket could not be retrieved" - - if formdata.webhook: - async with httpx.AsyncClient() as client: - await client.post( - formdata.webhook, - json={ - "form": ticket.form, - "name": ticket.name, - "email": ticket.email, - "content": ticket.ltext, - }, - timeout=40, - ) - return ticket - - ticket = await get_ticket(payment_hash) - assert ticket, "Newly paid ticket could not be retrieved" - return ticket - - -async def get_ticket(ticket_id: str) -> Optional[Tickets]: - row = await db.fetchone("SELECT * FROM lnticket.ticket WHERE id = ?", (ticket_id,)) - return Tickets(**row) if row else None - - -async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM lnticket.ticket WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [Tickets(**row) for row in rows] - - -async def delete_ticket(ticket_id: str) -> None: - await db.execute("DELETE FROM lnticket.ticket WHERE id = ?", (ticket_id,)) - - -# FORMS - - -async def create_form( - *, - wallet: str, - name: str, - webhook: Optional[str] = None, - description: str, - amount: int, - flatrate: int, -) -> Forms: - form_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO lnticket.form2 (id, wallet, name, webhook, description, flatrate, amount, amountmade) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - (form_id, wallet, name, webhook, description, flatrate, amount, 0), - ) - - form = await get_form(form_id) - assert form, "Newly created forms couldn't be retrieved" - return form - - -async def update_form(form_id: str, **kwargs) -> Forms: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE lnticket.form2 SET {q} WHERE id = ?", (*kwargs.values(), form_id) - ) - row = await db.fetchone("SELECT * FROM lnticket.form2 WHERE id = ?", (form_id,)) - assert row, "Newly updated form couldn't be retrieved" - return Forms(**row) - - -async def get_form(form_id: str) -> Optional[Forms]: - row = await db.fetchone("SELECT * FROM lnticket.form2 WHERE id = ?", (form_id,)) - return Forms(**row) if row else None - - -async def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM lnticket.form2 WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [Forms(**row) for row in rows] - - -async def delete_form(form_id: str) -> None: - await db.execute("DELETE FROM lnticket.form2 WHERE id = ?", (form_id,)) diff --git a/lnbits/extensions/lnticket/migrations.py b/lnbits/extensions/lnticket/migrations.py deleted file mode 100644 index abcd5c7f..00000000 --- a/lnbits/extensions/lnticket/migrations.py +++ /dev/null @@ -1,202 +0,0 @@ -async def m001_initial(db): - - await db.execute( - """ - CREATE TABLE lnticket.forms ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT NOT NULL, - costpword INTEGER NOT NULL, - amountmade INTEGER NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - await db.execute( - """ - CREATE TABLE lnticket.tickets ( - id TEXT PRIMARY KEY, - form TEXT NOT NULL, - email TEXT NOT NULL, - ltext TEXT NOT NULL, - name TEXT NOT NULL, - wallet TEXT NOT NULL, - sats INTEGER NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - -async def m002_changed(db): - - await db.execute( - """ - CREATE TABLE lnticket.ticket ( - id TEXT PRIMARY KEY, - form TEXT NOT NULL, - email TEXT NOT NULL, - ltext TEXT NOT NULL, - name TEXT NOT NULL, - wallet TEXT NOT NULL, - sats INTEGER NOT NULL, - paid BOOLEAN NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - for row in [ - list(row) for row in await db.fetchall("SELECT * FROM lnticket.tickets") - ]: - usescsv = "" - - for i in range(row[5]): - if row[7]: - usescsv += "," + str(i + 1) - else: - usescsv += "," + str(1) - usescsv = usescsv[1:] - await db.execute( - """ - INSERT INTO lnticket.ticket ( - id, - form, - email, - ltext, - name, - wallet, - sats, - paid - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - row[0], - row[1], - row[2], - row[3], - row[4], - row[5], - row[6], - True, - ), - ) - await db.execute("DROP TABLE lnticket.tickets") - - -async def m003_changed(db): - - await db.execute( - """ - CREATE TABLE lnticket.form ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - name TEXT NOT NULL, - webhook TEXT, - description TEXT NOT NULL, - costpword INTEGER NOT NULL, - amountmade INTEGER NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - for row in [list(row) for row in await db.fetchall("SELECT * FROM lnticket.forms")]: - usescsv = "" - - for i in range(row[5]): - if row[7]: - usescsv += "," + str(i + 1) - else: - usescsv += "," + str(1) - usescsv = usescsv[1:] - await db.execute( - """ - INSERT INTO lnticket.form ( - id, - wallet, - name, - webhook, - description, - costpword, - amountmade - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - row[0], - row[1], - row[2], - row[3], - row[4], - row[5], - row[6], - ), - ) - await db.execute("DROP TABLE lnticket.forms") - - -async def m004_changed(db): - - await db.execute( - """ - CREATE TABLE lnticket.form2 ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - name TEXT NOT NULL, - webhook TEXT, - description TEXT NOT NULL, - flatrate INTEGER DEFAULT 0, - amount INTEGER NOT NULL, - amountmade INTEGER NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - for row in [list(row) for row in await db.fetchall("SELECT * FROM lnticket.form")]: - usescsv = "" - - for i in range(row[5]): - if row[7]: - usescsv += "," + str(i + 1) - else: - usescsv += "," + str(1) - usescsv = usescsv[1:] - await db.execute( - """ - INSERT INTO lnticket.form2 ( - id, - wallet, - name, - webhook, - description, - amount, - amountmade - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - row[0], - row[1], - row[2], - row[3], - row[4], - row[5], - row[6], - ), - ) - await db.execute("DROP TABLE lnticket.form") diff --git a/lnbits/extensions/lnticket/models.py b/lnbits/extensions/lnticket/models.py deleted file mode 100644 index fdf4a5ff..00000000 --- a/lnbits/extensions/lnticket/models.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import NamedTuple - - -class Forms(NamedTuple): - id: str - wallet: str - name: str - webhook: str - description: str - amount: int - flatrate: int - amountmade: int - time: int - - -class Tickets(NamedTuple): - id: str - form: str - email: str - ltext: str - name: str - wallet: str - sats: int - paid: bool - time: int diff --git a/lnbits/extensions/lnticket/tasks.py b/lnbits/extensions/lnticket/tasks.py deleted file mode 100644 index 5160de1d..00000000 --- a/lnbits/extensions/lnticket/tasks.py +++ /dev/null @@ -1,37 +0,0 @@ -import json -import trio # type: ignore - -from lnbits.core.models import Payment -from lnbits.core.crud import create_payment -from lnbits.core import db as core_db -from lnbits.tasks import register_invoice_listener, internal_invoice_paid -from lnbits.helpers import urlsafe_short_hash - -from .crud import get_ticket, set_ticket_paid - - -async def register_listeners(): - invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) - register_invoice_listener(invoice_paid_chan_send) - await wait_for_paid_invoices(invoice_paid_chan_recv) - - -async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): - async for payment in invoice_paid_chan: - await on_invoice_paid(payment) - - -async def on_invoice_paid(payment: Payment) -> None: - if "lnticket" != payment.extra.get("tag"): - # not a lnticket invoice - return - - ticket = await get_ticket(payment.checking_id) - if not ticket: - print("this should never happen", payment) - return - - await payment.set_pending(False) - await set_ticket_paid(payment.payment_hash) - _ticket = await get_ticket(payment.checking_id) - print("ticket", _ticket) diff --git a/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html b/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html deleted file mode 100644 index 69328f38..00000000 --- a/lnbits/extensions/lnticket/templates/lnticket/_api_docs.html +++ /dev/null @@ -1,22 +0,0 @@ - - - -
- Support Tickets: Get paid sats to answer questions -
-

- Charge people per word for contacting you. Possible applications incude, - paid support ticketing, PAYG language services, contact spam - protection.
- - Created by, Ben Arc -

-
-
-
diff --git a/lnbits/extensions/lnticket/templates/lnticket/display.html b/lnbits/extensions/lnticket/templates/lnticket/display.html deleted file mode 100644 index 3b48766c..00000000 --- a/lnbits/extensions/lnticket/templates/lnticket/display.html +++ /dev/null @@ -1,202 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -

{{ form_name }}

-
-
{{ form_desc }}
-
- - - - - -

{% raw %}{{amountWords}}{% endraw %}

-
- Submit - Cancel -
-
-
-
-
- - - - - - -
- Copy invoice - Close -
-
-
-
- -{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/lnticket/templates/lnticket/index.html b/lnbits/extensions/lnticket/templates/lnticket/index.html deleted file mode 100644 index bc9fe9a4..00000000 --- a/lnbits/extensions/lnticket/templates/lnticket/index.html +++ /dev/null @@ -1,490 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New Form - - - - - -
-
-
Forms
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
- - - -
-
-
Tickets
-
- -
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
-
- - -
- {{SITE_TITLE}} Support Tickets extension -
-
- - - {% include "lnticket/_api_docs.html" %} - -
-
- - - - - - - - - -
-
- -
-
- -
-
- -
- Update Form - - Create Form - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/lnticket/views.py b/lnbits/extensions/lnticket/views.py deleted file mode 100644 index 00ba3239..00000000 --- a/lnbits/extensions/lnticket/views.py +++ /dev/null @@ -1,34 +0,0 @@ -from quart import g, abort, render_template - -from lnbits.core.crud import get_wallet -from lnbits.decorators import check_user_exists, validate_uuids -from http import HTTPStatus - -from . import lnticket_ext -from .crud import get_form - - -@lnticket_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("lnticket/index.html", user=g.user) - - -@lnticket_ext.route("/") -async def display(form_id): - form = await get_form(form_id) - if not form: - abort(HTTPStatus.NOT_FOUND, "LNTicket does not exist.") - - wallet = await get_wallet(form.wallet) - - return await render_template( - "lnticket/display.html", - form_id=form.id, - form_name=form.name, - form_desc=form.description, - form_amount=form.amount, - form_flatrate=form.flatrate, - form_wallet=wallet.inkey, - ) diff --git a/lnbits/extensions/lnticket/views_api.py b/lnbits/extensions/lnticket/views_api.py deleted file mode 100644 index 76fe222e..00000000 --- a/lnbits/extensions/lnticket/views_api.py +++ /dev/null @@ -1,179 +0,0 @@ -import re -from quart import g, jsonify, request -from http import HTTPStatus - -from lnbits.core.crud import get_user, get_wallet -from lnbits.core.services import create_invoice, check_invoice_status -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import lnticket_ext -from .crud import ( - create_ticket, - set_ticket_paid, - get_ticket, - get_tickets, - delete_ticket, - create_form, - update_form, - get_form, - get_forms, - delete_form, -) - - -# FORMS - - -@lnticket_ext.route("/api/v1/forms", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_forms(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([form._asdict() for form in await get_forms(wallet_ids)]), - HTTPStatus.OK, - ) - - -@lnticket_ext.route("/api/v1/forms", methods=["POST"]) -@lnticket_ext.route("/api/v1/forms/", methods=["PUT"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "wallet": {"type": "string", "empty": False, "required": True}, - "name": {"type": "string", "empty": False, "required": True}, - "webhook": {"type": "string", "required": False}, - "description": {"type": "string", "min": 0, "required": True}, - "amount": {"type": "integer", "min": 0, "required": True}, - "flatrate": {"type": "integer", "required": True}, - } -) -async def api_form_create(form_id=None): - if form_id: - form = await get_form(form_id) - - if not form: - return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND - - if form.wallet != g.wallet.id: - return jsonify({"message": "Not your form."}), HTTPStatus.FORBIDDEN - - form = await update_form(form_id, **g.data) - else: - form = await create_form(**g.data) - return jsonify(form._asdict()), HTTPStatus.CREATED - - -@lnticket_ext.route("/api/v1/forms/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_form_delete(form_id): - form = await get_form(form_id) - - if not form: - return jsonify({"message": "Form does not exist."}), HTTPStatus.NOT_FOUND - - if form.wallet != g.wallet.id: - return jsonify({"message": "Not your form."}), HTTPStatus.FORBIDDEN - - await delete_form(form_id) - - return "", HTTPStatus.NO_CONTENT - - -#########tickets########## - - -@lnticket_ext.route("/api/v1/tickets", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_tickets(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([form._asdict() for form in await get_tickets(wallet_ids)]), - HTTPStatus.OK, - ) - - -@lnticket_ext.route("/api/v1/tickets/", methods=["POST"]) -@api_validate_post_request( - schema={ - "form": {"type": "string", "empty": False, "required": True}, - "name": {"type": "string", "empty": False, "required": True}, - "email": {"type": "string", "empty": True, "required": True}, - "ltext": {"type": "string", "empty": False, "required": True}, - "sats": {"type": "integer", "min": 0, "required": True}, - } -) -async def api_ticket_make_ticket(form_id): - form = await get_form(form_id) - if not form: - return jsonify({"message": "LNTicket does not exist."}), HTTPStatus.NOT_FOUND - - nwords = len(re.split(r"\s+", g.data["ltext"])) - sats = g.data["sats"] - - try: - payment_hash, payment_request = await create_invoice( - wallet_id=form.wallet, - amount=sats, - memo=f"ticket with {nwords} words on {form_id}", - extra={"tag": "lnticket"}, - ) - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - - ticket = await create_ticket( - payment_hash=payment_hash, wallet=form.wallet, **g.data - ) - - if not ticket: - return ( - jsonify({"message": "LNTicket could not be fetched."}), - HTTPStatus.NOT_FOUND, - ) - - return ( - jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), - HTTPStatus.OK, - ) - - -@lnticket_ext.route("/api/v1/tickets/", methods=["GET"]) -async def api_ticket_send_ticket(payment_hash): - ticket = await get_ticket(payment_hash) - try: - status = await check_invoice_status(ticket.wallet, payment_hash) - is_paid = not status.pending - except Exception: - return jsonify({"paid": False}), HTTPStatus.OK - - if is_paid: - wallet = await get_wallet(ticket.wallet) - payment = await wallet.get_payment(payment_hash) - await payment.set_pending(False) - ticket = await set_ticket_paid(payment_hash=payment_hash) - return jsonify({"paid": True}), HTTPStatus.OK - - return jsonify({"paid": False}), HTTPStatus.OK - - -@lnticket_ext.route("/api/v1/tickets/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_ticket_delete(ticket_id): - ticket = await get_ticket(ticket_id) - - if not ticket: - return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND - - if ticket.wallet != g.wallet.id: - return jsonify({"message": "Not your ticket."}), HTTPStatus.FORBIDDEN - - await delete_ticket(ticket_id) - - return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/lnurlp/README.md b/lnbits/extensions/lnurlp/README.md deleted file mode 100644 index 0832bfb7..00000000 --- a/lnbits/extensions/lnurlp/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# LNURLp - -## Create a static QR code people can use to pay over Lightning Network - -LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL-pay is a link that wallets use to fetch an invoice from a server on-demand. The link or QR code is fixed, but each time it is read by a compatible wallet a new invoice is issued by the service and sent to the wallet. - -[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) - -## Usage - -1. Create an LNURLp (New Pay link)\ - ![create lnurlp](https://i.imgur.com/rhUBJFy.jpg) - - - select your wallets - - make a small description - - enter amount - - if _Fixed amount_ is unchecked you'll have the option to configure a Max and Min amount - - you can set the currency to something different than sats. For example if you choose EUR, the satoshi amount will be calculated when a user scans the LNURLp - - You can ask the user to send a comment that will be sent along with the payment (for example a comment to a blog post) - - Webhook URL allows to call an URL when the LNURLp is paid - - Success mesage, will send a message back to the user after a successful payment, for example a thank you note - - Success URL, will send back a clickable link to the user. Access to some hidden content, or a download link - -2. Use the shareable link or view the LNURLp you just created\ - ![LNURLp](https://i.imgur.com/C8s1P0Q.jpg) - - you can now open your LNURLp and copy the LNURL, get the shareable link or print it\ - ![view lnurlp](https://i.imgur.com/4n41S7T.jpg) diff --git a/lnbits/extensions/lnurlp/__init__.py b/lnbits/extensions/lnurlp/__init__.py deleted file mode 100644 index d820b197..00000000 --- a/lnbits/extensions/lnurlp/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_lnurlp") - -lnurlp_ext: Blueprint = Blueprint( - "lnurlp", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa -from .lnurl import * # noqa -from .tasks import register_listeners - -from lnbits.tasks import record_async - -lnurlp_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/lnurlp/config.json b/lnbits/extensions/lnurlp/config.json deleted file mode 100644 index 294afe73..00000000 --- a/lnbits/extensions/lnurlp/config.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "LNURLp", - "short_description": "Make reusable LNURL pay links", - "icon": "receipt", - "contributors": [ - "arcbtc", - "eillarra", - "fiatjaf" - ] -} diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py deleted file mode 100644 index b1744a64..00000000 --- a/lnbits/extensions/lnurlp/crud.py +++ /dev/null @@ -1,103 +0,0 @@ -from typing import List, Optional, Union - -from lnbits.db import SQLITE -from . import db -from .models import PayLink - - -async def create_pay_link( - *, - wallet_id: str, - description: str, - min: int, - max: int, - comment_chars: int = 0, - currency: Optional[str] = None, - webhook_url: Optional[str] = None, - success_text: Optional[str] = None, - success_url: Optional[str] = None, -) -> PayLink: - - returning = "" if db.type == SQLITE else "RETURNING ID" - method = db.execute if db.type == SQLITE else db.fetchone - - result = await (method)( - f""" - INSERT INTO lnurlp.pay_links ( - wallet, - description, - min, - max, - served_meta, - served_pr, - webhook_url, - success_text, - success_url, - comment_chars, - currency - ) - VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?) - {returning} - """, - ( - wallet_id, - description, - min, - max, - webhook_url, - success_text, - success_url, - comment_chars, - currency, - ), - ) - if db.type == SQLITE: - link_id = result._result_proxy.lastrowid - else: - link_id = result[0] - - link = await get_pay_link(link_id) - assert link, "Newly created link couldn't be retrieved" - return link - - -async def get_pay_link(link_id: int) -> Optional[PayLink]: - row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) - return PayLink.from_row(row) if row else None - - -async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f""" - SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q}) - ORDER BY Id - """, - (*wallet_ids,), - ) - return [PayLink.from_row(row) for row in rows] - - -async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) - ) - row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) - return PayLink.from_row(row) if row else None - - -async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]: - q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) - ) - row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) - return PayLink.from_row(row) if row else None - - -async def delete_pay_link(link_id: int) -> None: - await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,)) diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py deleted file mode 100644 index 936d51f3..00000000 --- a/lnbits/extensions/lnurlp/lnurl.py +++ /dev/null @@ -1,111 +0,0 @@ -import hashlib -import math -from http import HTTPStatus -from quart import jsonify, url_for, request -from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore - -from lnbits.core.services import create_invoice -from lnbits.utils.exchange_rates import get_fiat_rate_satoshis - -from . import lnurlp_ext -from .crud import increment_pay_link - - -@lnurlp_ext.route("/api/v1/lnurl/", methods=["GET"]) -async def api_lnurl_response(link_id): - link = await increment_pay_link(link_id, served_meta=1) - if not link: - return ( - jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), - HTTPStatus.OK, - ) - - rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 - resp = LnurlPayResponse( - callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True), - min_sendable=math.ceil(link.min * rate) * 1000, - max_sendable=round(link.max * rate) * 1000, - metadata=link.lnurlpay_metadata, - ) - params = resp.dict() - - if link.comment_chars > 0: - params["commentAllowed"] = link.comment_chars - - return jsonify(params), HTTPStatus.OK - - -@lnurlp_ext.route("/api/v1/lnurl/cb/", methods=["GET"]) -async def api_lnurl_callback(link_id): - link = await increment_pay_link(link_id, served_pr=1) - if not link: - return ( - jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), - HTTPStatus.OK, - ) - - min, max = link.min, link.max - rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1 - if link.currency: - # allow some fluctuation (as the fiat price may have changed between the calls) - min = rate * 995 * link.min - max = rate * 1010 * link.max - else: - min = link.min * 1000 - max = link.max * 1000 - - amount_received = int(request.args.get("amount") or 0) - if amount_received < min: - return ( - jsonify( - LnurlErrorResponse( - reason=f"Amount {amount_received} is smaller than minimum {min}." - ).dict() - ), - HTTPStatus.OK, - ) - elif amount_received > max: - return ( - jsonify( - LnurlErrorResponse( - reason=f"Amount {amount_received} is greater than maximum {max}." - ).dict() - ), - HTTPStatus.OK, - ) - - comment = request.args.get("comment") - if len(comment or "") > link.comment_chars: - return ( - jsonify( - LnurlErrorResponse( - reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}" - ).dict() - ), - HTTPStatus.OK, - ) - - payment_hash, payment_request = await create_invoice( - wallet_id=link.wallet, - amount=int(amount_received / 1000), - memo=link.description, - description_hash=hashlib.sha256( - link.lnurlpay_metadata.encode("utf-8") - ).digest(), - extra={"tag": "lnurlp", "link": link.id, "comment": comment}, - ) - - success_action = link.success_action(payment_hash) - if success_action: - resp = LnurlPayActionResponse( - pr=payment_request, - success_action=success_action, - routes=[], - ) - else: - resp = LnurlPayActionResponse( - pr=payment_request, - routes=[], - ) - - return jsonify(resp.dict()), HTTPStatus.OK diff --git a/lnbits/extensions/lnurlp/migrations.py b/lnbits/extensions/lnurlp/migrations.py deleted file mode 100644 index 428bde2c..00000000 --- a/lnbits/extensions/lnurlp/migrations.py +++ /dev/null @@ -1,52 +0,0 @@ -async def m001_initial(db): - """ - Initial pay table. - """ - await db.execute( - f""" - CREATE TABLE lnurlp.pay_links ( - id {db.serial_primary_key}, - wallet TEXT NOT NULL, - description TEXT NOT NULL, - amount INTEGER NOT NULL, - served_meta INTEGER NOT NULL, - served_pr INTEGER NOT NULL - ); - """ - ) - - -async def m002_webhooks_and_success_actions(db): - """ - Webhooks and success actions. - """ - await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_url TEXT;") - await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_text TEXT;") - await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;") - await db.execute( - f""" - CREATE TABLE lnurlp.invoices ( - pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id), - payment_hash TEXT NOT NULL, - webhook_sent INT, -- null means not sent, otherwise store status - expiry INT - ); - """ - ) - - -async def m003_min_max_comment_fiat(db): - """ - Support for min/max amounts, comments and fiat prices that get - converted automatically to satoshis based on some API. - """ - await db.execute( - "ALTER TABLE lnurlp.pay_links ADD COLUMN currency TEXT;" - ) # null = satoshis - await db.execute( - "ALTER TABLE lnurlp.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;" - ) - await db.execute("ALTER TABLE lnurlp.pay_links RENAME COLUMN amount TO min;") - await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;") - await db.execute("UPDATE lnurlp.pay_links SET max = min;") - await db.execute("DROP TABLE lnurlp.invoices") diff --git a/lnbits/extensions/lnurlp/models.py b/lnbits/extensions/lnurlp/models.py deleted file mode 100644 index c08dca03..00000000 --- a/lnbits/extensions/lnurlp/models.py +++ /dev/null @@ -1,55 +0,0 @@ -import json -from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult -from quart import url_for -from typing import NamedTuple, Optional, Dict -from sqlite3 import Row -from lnbits.lnurl import encode as lnurl_encode # type: ignore -from lnurl.types import LnurlPayMetadata # type: ignore - - -class PayLink(NamedTuple): - id: int - wallet: str - description: str - min: int - served_meta: int - served_pr: int - webhook_url: str - success_text: str - success_url: str - currency: str - comment_chars: int - max: int - - @classmethod - def from_row(cls, row: Row) -> "PayLink": - data = dict(row) - return cls(**data) - - @property - def lnurl(self) -> str: - url = url_for("lnurlp.api_lnurl_response", link_id=self.id, _external=True) - return lnurl_encode(url) - - @property - def lnurlpay_metadata(self) -> LnurlPayMetadata: - return LnurlPayMetadata(json.dumps([["text/plain", self.description]])) - - def success_action(self, payment_hash: str) -> Optional[Dict]: - if self.success_url: - url: ParseResult = urlparse(self.success_url) - qs: Dict = parse_qs(url.query) - qs["payment_hash"] = payment_hash - url = url._replace(query=urlencode(qs, doseq=True)) - return { - "tag": "url", - "description": self.success_text or "~", - "url": urlunparse(url), - } - elif self.success_text: - return { - "tag": "message", - "message": self.success_text, - } - else: - return None diff --git a/lnbits/extensions/lnurlp/static/js/index.js b/lnbits/extensions/lnurlp/static/js/index.js deleted file mode 100644 index efd0fbd8..00000000 --- a/lnbits/extensions/lnurlp/static/js/index.js +++ /dev/null @@ -1,227 +0,0 @@ -/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ - -Vue.component(VueQrcode.name, VueQrcode) - -var locationPath = [ - window.location.protocol, - '//', - window.location.host, - window.location.pathname -].join('') - -var mapPayLink = obj => { - obj._data = _.clone(obj) - obj.date = Quasar.utils.date.formatDate( - new Date(obj.time * 1000), - 'YYYY-MM-DD HH:mm' - ) - obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount) - obj.print_url = [locationPath, 'print/', obj.id].join('') - obj.pay_url = [locationPath, obj.id].join('') - return obj -} - -new Vue({ - el: '#vue', - mixins: [windowMixin], - data() { - return { - currencies: [], - fiatRates: {}, - checker: null, - payLinks: [], - payLinksTable: { - pagination: { - rowsPerPage: 10 - } - }, - formDialog: { - show: false, - fixedAmount: true, - data: {} - }, - qrCodeDialog: { - show: false, - data: null - } - } - }, - methods: { - getPayLinks() { - LNbits.api - .request( - 'GET', - '/lnurlp/api/v1/links?all_wallets', - this.g.user.wallets[0].inkey - ) - .then(response => { - this.payLinks = response.data.map(mapPayLink) - }) - .catch(err => { - clearInterval(this.checker) - LNbits.utils.notifyApiError(err) - }) - }, - closeFormDialog() { - this.resetFormData() - }, - openQrCodeDialog(linkId) { - var link = _.findWhere(this.payLinks, {id: linkId}) - if (link.currency) this.updateFiatRate(link.currency) - - this.qrCodeDialog.data = { - id: link.id, - amount: - (link.min === link.max ? link.min : `${link.min} - ${link.max}`) + - ' ' + - (link.currency || 'sat'), - currency: link.currency, - comments: link.comment_chars - ? `${link.comment_chars} characters` - : 'no', - webhook: link.webhook_url || 'nowhere', - success: - link.success_text || link.success_url - ? 'Display message "' + - link.success_text + - '"' + - (link.success_url ? ' and URL "' + link.success_url + '"' : '') - : 'do nothing', - lnurl: link.lnurl, - pay_url: link.pay_url, - print_url: link.print_url - } - this.qrCodeDialog.show = true - }, - openUpdateDialog(linkId) { - const link = _.findWhere(this.payLinks, {id: linkId}) - if (link.currency) this.updateFiatRate(link.currency) - - this.formDialog.data = _.clone(link._data) - this.formDialog.show = true - this.formDialog.fixedAmount = - this.formDialog.data.min === this.formDialog.data.max - }, - sendFormData() { - const wallet = _.findWhere(this.g.user.wallets, { - id: this.formDialog.data.wallet - }) - var data = _.omit(this.formDialog.data, 'wallet') - - if (this.formDialog.fixedAmount) data.max = data.min - if (data.currency === 'satoshis') data.currency = null - if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0 - - if (data.id) { - this.updatePayLink(wallet, data) - } else { - this.createPayLink(wallet, data) - } - }, - resetFormData() { - this.formDialog = { - show: false, - fixedAmount: true, - data: {} - } - }, - updatePayLink(wallet, data) { - let values = _.omit( - _.pick( - data, - 'description', - 'min', - 'max', - 'webhook_url', - 'success_text', - 'success_url', - 'comment_chars', - 'currency' - ), - (value, key) => - (key === 'webhook_url' || - key === 'success_text' || - key === 'success_url') && - (value === null || value === '') - ) - - LNbits.api - .request( - 'PUT', - '/lnurlp/api/v1/links/' + data.id, - wallet.adminkey, - values - ) - .then(response => { - this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id) - this.payLinks.push(mapPayLink(response.data)) - this.formDialog.show = false - this.resetFormData() - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - createPayLink(wallet, data) { - LNbits.api - .request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data) - .then(response => { - this.payLinks.push(mapPayLink(response.data)) - this.formDialog.show = false - this.resetFormData() - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - deletePayLink(linkId) { - var link = _.findWhere(this.payLinks, {id: linkId}) - - LNbits.utils - .confirmDialog('Are you sure you want to delete this pay link?') - .onOk(() => { - LNbits.api - .request( - 'DELETE', - '/lnurlp/api/v1/links/' + linkId, - _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey - ) - .then(response => { - this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId) - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }) - }, - updateFiatRate(currency) { - LNbits.api - .request('GET', '/lnurlp/api/v1/rate/' + currency, null) - .then(response => { - let rates = _.clone(this.fiatRates) - rates[currency] = response.data.rate - this.fiatRates = rates - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - } - }, - created() { - if (this.g.user.wallets.length) { - var getPayLinks = this.getPayLinks - getPayLinks() - this.checker = setInterval(() => { - getPayLinks() - }, 20000) - } - LNbits.api - .request('GET', '/lnurlp/api/v1/currencies') - .then(response => { - this.currencies = ['satoshis', ...response.data] - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - } -}) diff --git a/lnbits/extensions/lnurlp/tasks.py b/lnbits/extensions/lnurlp/tasks.py deleted file mode 100644 index e8d6a453..00000000 --- a/lnbits/extensions/lnurlp/tasks.py +++ /dev/null @@ -1,61 +0,0 @@ -import trio -import json -import httpx - -from lnbits.core import db as core_db -from lnbits.core.models import Payment -from lnbits.tasks import register_invoice_listener - -from .crud import get_pay_link - - -async def register_listeners(): - invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) - register_invoice_listener(invoice_paid_chan_send) - await wait_for_paid_invoices(invoice_paid_chan_recv) - - -async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): - async for payment in invoice_paid_chan: - await on_invoice_paid(payment) - - -async def on_invoice_paid(payment: Payment) -> None: - if "lnurlp" != payment.extra.get("tag"): - # not an lnurlp invoice - return - - if payment.extra.get("wh_status"): - # this webhook has already been sent - return - - pay_link = await get_pay_link(payment.extra.get("link", -1)) - if pay_link and pay_link.webhook_url: - async with httpx.AsyncClient() as client: - try: - r = await client.post( - pay_link.webhook_url, - json={ - "payment_hash": payment.payment_hash, - "payment_request": payment.bolt11, - "amount": payment.amount, - "comment": payment.extra.get("comment"), - "lnurlp": pay_link.id, - }, - timeout=40, - ) - await mark_webhook_sent(payment, r.status_code) - except (httpx.ConnectError, httpx.RequestError): - await mark_webhook_sent(payment, -1) - - -async def mark_webhook_sent(payment: Payment, status: int) -> None: - payment.extra["wh_status"] = status - - await core_db.execute( - """ - UPDATE apipayments SET extra = ? - WHERE hash = ? - """, - (json.dumps(payment.extra), payment.payment_hash), - ) diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html deleted file mode 100644 index d47ab1f1..00000000 --- a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html +++ /dev/null @@ -1,128 +0,0 @@ - - - - - GET /lnurlp/api/v1/links -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [<pay_link_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - GET /lnurlp/api/v1/links/<pay_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 201 CREATED (application/json) -
- {"lnurl": <string>} -
Curl example
- curl -X GET {{ request.url_root }}api/v1/links/<pay_id> -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - POST /lnurlp/api/v1/links -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
- {"description": <string> "amount": <integer> "max": <integer> "min": <integer> "comment_chars": <integer>} -
- Returns 201 CREATED (application/json) -
- {"lnurl": <string>} -
Curl example
- curl -X POST {{ request.url_root }}api/v1/links -d '{"description": - <string>, "amount": <integer>, "max": <integer>, "min": <integer>, "comment_chars": <integer>}' -H "Content-type: - application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" - -
-
-
- - - - PUT - /lnurlp/api/v1/links/<pay_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
- {"description": <string>, "amount": <integer>} -
- Returns 200 OK (application/json) -
- {"lnurl": <string>} -
Curl example
- curl -X PUT {{ request.url_root }}api/v1/links/<pay_id> -d - '{"description": <string>, "amount": <integer>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - -
-
-
- - - - DELETE - /lnurlp/api/v1/links/<pay_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.url_root }}api/v1/links/<pay_id> -H - "X-Api-Key: {{ g.user.wallets[0].adminkey }}" - -
-
-
-
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html b/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html deleted file mode 100644 index da46d9c4..00000000 --- a/lnbits/extensions/lnurlp/templates/lnurlp/_lnurl.html +++ /dev/null @@ -1,28 +0,0 @@ - - - -

- WARNING: LNURL must be used over https or TOR
- LNURL is a range of lightning-network standards that allow us to use - lightning-network differently. An LNURL-pay is a link that wallets use - to fetch an invoice from a server on-demand. The link or QR code is - fixed, but each time it is read by a compatible wallet a new QR code is - issued by the service. It can be used to activate machines without them - having to maintain an electronic screen to generate and show invoices - locally, or to sell any predefined good or service automatically. -

-

- Exploring LNURL and finding use cases, is really helping inform - lightning protocol development, rather than the protocol dictating how - lightning-network should be engaged with. -

- Check - Awesome LNURL - for further information. -
-
-
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/display.html b/lnbits/extensions/lnurlp/templates/lnurlp/display.html deleted file mode 100644 index a2e0389c..00000000 --- a/lnbits/extensions/lnurlp/templates/lnurlp/display.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - - -
- Copy LNURL -
-
-
-
-
- - -
LNbits LNURL-pay link
-

Use an LNURL compatible bitcoin wallet to pay.

-
- - - {% include "lnurlp/_lnurl.html" %} - -
-
-
-{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html deleted file mode 100644 index c535f2fb..00000000 --- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html +++ /dev/null @@ -1,312 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New pay link - - - - - -
-
-
Pay links
-
-
- - {% raw %} - - - {% endraw %} - -
-
-
- -
- - -
- {{SITE_TITLE}} LNURL-pay extension -
-
- - - - {% include "lnurlp/_api_docs.html" %} - - {% include "lnurlp/_lnurl.html" %} - - -
-
- - - - - - - -
- - -
-
-
- -
-
- -
-
- - - - -
- Update pay link - Create pay link - Cancel -
-
-
-
- - - - {% raw %} - - - -

- ID: {{ qrCodeDialog.data.id }}
- Amount: {{ qrCodeDialog.data.amount }}
- {{ qrCodeDialog.data.currency }} price: {{ - fiatRates[qrCodeDialog.data.currency] ? - fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}
- Accepts comments: {{ qrCodeDialog.data.comments }}
- Dispatches webhook to: {{ qrCodeDialog.data.webhook - }}
- On success: {{ qrCodeDialog.data.success }}
-

- {% endraw %} -
- Copy LNURL - Shareable link - - Close -
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html b/lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html deleted file mode 100644 index a6a98f4d..00000000 --- a/lnbits/extensions/lnurlp/templates/lnurlp/print_qr.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "print.html" %} {% block page %} -
-
- -
-
-{% endblock %} {% block styles %} - -{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/lnurlp/views.py b/lnbits/extensions/lnurlp/views.py deleted file mode 100644 index 72f30c13..00000000 --- a/lnbits/extensions/lnurlp/views.py +++ /dev/null @@ -1,32 +0,0 @@ -from quart import g, abort, render_template -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import lnurlp_ext -from .crud import get_pay_link - - -@lnurlp_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("lnurlp/index.html", user=g.user) - - -@lnurlp_ext.route("/") -async def display(link_id): - link = await get_pay_link(link_id) - if not link: - abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.") - - return await render_template("lnurlp/display.html", link=link) - - -@lnurlp_ext.route("/print/") -async def print_qr(link_id): - link = await get_pay_link(link_id) - if not link: - abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.") - - return await render_template("lnurlp/print_qr.html", link=link) diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py deleted file mode 100644 index af670c83..00000000 --- a/lnbits/extensions/lnurlp/views_api.py +++ /dev/null @@ -1,142 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus -from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore - -from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request -from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis - -from . import lnurlp_ext -from .crud import ( - create_pay_link, - get_pay_link, - get_pay_links, - update_pay_link, - delete_pay_link, -) - - -@lnurlp_ext.route("/api/v1/currencies", methods=["GET"]) -async def api_list_currencies_available(): - return jsonify(list(currencies.keys())) - - -@lnurlp_ext.route("/api/v1/links", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_links(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - try: - return ( - jsonify( - [ - {**link._asdict(), **{"lnurl": link.lnurl}} - for link in await get_pay_links(wallet_ids) - ] - ), - HTTPStatus.OK, - ) - except LnurlInvalidUrl: - return ( - jsonify( - { - "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." - } - ), - HTTPStatus.UPGRADE_REQUIRED, - ) - - -@lnurlp_ext.route("/api/v1/links/", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_link_retrieve(link_id): - link = await get_pay_link(link_id) - - if not link: - return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND - - if link.wallet != g.wallet.id: - return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN - - return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK - - -@lnurlp_ext.route("/api/v1/links", methods=["POST"]) -@lnurlp_ext.route("/api/v1/links/", methods=["PUT"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "description": {"type": "string", "empty": False, "required": True}, - "min": {"type": "number", "min": 0.01, "required": True}, - "max": {"type": "number", "min": 0.01, "required": True}, - "currency": {"type": "string", "nullable": True, "required": False}, - "comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800}, - "webhook_url": {"type": "string", "required": False}, - "success_text": {"type": "string", "required": False}, - "success_url": {"type": "string", "required": False}, - } -) -async def api_link_create_or_update(link_id=None): - if g.data["min"] > g.data["max"]: - return jsonify({"message": "Min is greater than max."}), HTTPStatus.BAD_REQUEST - - if g.data.get("currency") == None and ( - round(g.data["min"]) != g.data["min"] or round(g.data["max"]) != g.data["max"] - ): - return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST - - if "success_url" in g.data and g.data["success_url"][:8] != "https://": - return ( - jsonify({"message": "Success URL must be secure https://..."}), - HTTPStatus.BAD_REQUEST, - ) - - if link_id: - link = await get_pay_link(link_id) - - if not link: - return ( - jsonify({"message": "Pay link does not exist."}), - HTTPStatus.NOT_FOUND, - ) - - if link.wallet != g.wallet.id: - return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN - - link = await update_pay_link(link_id, **g.data) - else: - link = await create_pay_link(wallet_id=g.wallet.id, **g.data) - - return ( - jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), - HTTPStatus.OK if link_id else HTTPStatus.CREATED, - ) - - -@lnurlp_ext.route("/api/v1/links/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_link_delete(link_id): - link = await get_pay_link(link_id) - - if not link: - return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND - - if link.wallet != g.wallet.id: - return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN - - await delete_pay_link(link_id) - - return "", HTTPStatus.NO_CONTENT - - -@lnurlp_ext.route("/api/v1/rate/", methods=["GET"]) -async def api_check_fiat_rate(currency): - try: - rate = await get_fiat_rate_satoshis(currency) - except AssertionError: - rate = None - - return jsonify({"rate": rate}), HTTPStatus.OK diff --git a/lnbits/extensions/ngrok/README.md b/lnbits/extensions/ngrok/README.md deleted file mode 100644 index 666f95bc..00000000 --- a/lnbits/extensions/ngrok/README.md +++ /dev/null @@ -1,20 +0,0 @@ -

Ngrok

-

Serve lnbits over https for free using ngrok

- - - -

How it works

- -When enabled, ngrok creates a tunnel to ngrok.io with https support and tells you the https web address where you can access your lnbits instance. If you are not the first user to enable it, it doesn't create a new one, it just tells you the existing one. Useful for creating/managing/using lnurls, which must be served either via https or via tor. Note that if you restart your device, your device will generate a new url. If anyone is using your old one for wallets, lnurls, etc., whatever they are doing will stop working. - -

Installation

- -Check the Extensions page on your instance of lnbits. If you have copy of lnbits with ngrok as one of the built in extensions, click Enable -- that's the only thing you need to do to install it. - -If your copy of lnbits does not have ngrok as one of the built in extensions, stop lnbits, create go into your lnbits folder, and run this command: ./venv/bin/pip install pyngrok. Then go into the lnbits subdirectory and the extensions subdirectory within that. (So lnbits > lnbits > extensions.) Create a new subdirectory in there called freetunnel, download this repository as a zip file, and unzip it in the freetunnel directory. If your unzipper creates a new "freetunnel" subdirectory, take everything out of there and put it in the freetunnel directory you created. Then go back to the top level lnbits directory and run these commands: - -``` -./venv/bin/quart assets -./venv/bin/quart migrate -./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()' -``` diff --git a/lnbits/extensions/ngrok/__init__.py b/lnbits/extensions/ngrok/__init__.py deleted file mode 100644 index 4933aa7f..00000000 --- a/lnbits/extensions/ngrok/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_ngrok") - -ngrok_ext: Blueprint = Blueprint("ngrok", __name__, template_folder="templates") - -from .views import * # noqa diff --git a/lnbits/extensions/ngrok/__pycache__/.gitkeep b/lnbits/extensions/ngrok/__pycache__/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/lnbits/extensions/ngrok/__pycache__/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lnbits/extensions/ngrok/config.json.example b/lnbits/extensions/ngrok/config.json.example deleted file mode 100644 index 58e9ff8e..00000000 --- a/lnbits/extensions/ngrok/config.json.example +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Ngrok", - "short_description": "Serve lnbits over https for free using ngrok", - "icon": "trip_origin", - "contributors": ["supertestnet"] -} diff --git a/lnbits/extensions/ngrok/migrations.py b/lnbits/extensions/ngrok/migrations.py deleted file mode 100644 index f9b8b37d..00000000 --- a/lnbits/extensions/ngrok/migrations.py +++ /dev/null @@ -1,11 +0,0 @@ -# async def m001_initial(db): - -# await db.execute( -# """ -# CREATE TABLE example.example ( -# id TEXT PRIMARY KEY, -# wallet TEXT NOT NULL, -# time TIMESTAMP NOT NULL DEFAULT """ + db.timestamp_now + """ -# ); -# """ -# ) diff --git a/lnbits/extensions/ngrok/templates/ngrok/index.html b/lnbits/extensions/ngrok/templates/ngrok/index.html deleted file mode 100644 index 3af4fa44..00000000 --- a/lnbits/extensions/ngrok/templates/ngrok/index.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} - -
-
- - -
- Access this lnbits instance at the following url -
- -

{{ ngrok }}

-
-
-
- -
- - -
Ngrok extension
-
- - - -

- Note that if you restart your device, your device will generate a - new url. If anyone is using your old one for wallets, lnurls, - etc., whatever they are doing will stop working. -

- Created by - Supertestnet. -
-
-
-
-
-
- -{% endblock %}{% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/ngrok/views.py b/lnbits/extensions/ngrok/views.py deleted file mode 100644 index 732ad7ed..00000000 --- a/lnbits/extensions/ngrok/views.py +++ /dev/null @@ -1,30 +0,0 @@ -from quart import g, render_template - -from lnbits.decorators import check_user_exists, validate_uuids - -from pyngrok import conf, ngrok -from . import ngrok_ext -from os import getenv - - -def log_event_callback(log): - string = str(log) - string2 = string[string.find('url="https') : string.find('url="https') + 40] - if string2: - string3 = string2 - string4 = string3[4:] - global string5 - string5 = string4.replace('"', "") - - -conf.get_default().log_event_callback = log_event_callback - -port = getenv("PORT") -ngrok_tunnel = ngrok.connect(port) - - -@ngrok_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("ngrok/index.html", ngrok=string5, user=g.user) diff --git a/lnbits/extensions/offlineshop/README.md b/lnbits/extensions/offlineshop/README.md deleted file mode 100644 index 79bbe41d..00000000 --- a/lnbits/extensions/offlineshop/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Offline Shop - -## Create QR codes for each product and display them on your store for receiving payments Offline - -[![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop') - -LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device. - -Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a costumer chooses an item, scans the QR code, gets the description and price. After payment, the costumer gets a confirmation code that the merchant can validate to be sure the payment was successful. - -Costumers must use an LNURL pay capable wallet. - -[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) - -## Usage - -1. Entering the Offline shop extension you'll see an Items list, the Shop wallet and a Wordslist\ - ![offline shop back office](https://i.imgur.com/Ei7cxj9.png) -2. Begin by creating an item, click "ADD NEW ITEM" - - set the item name and a small description - - you can set an optional, preferably square image, that will show up on the costumer wallet - _depending on wallet_ - - set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time costumer scans to pay\ - ![add new item](https://i.imgur.com/pkZqRgj.png) -3. After creating some products, click on "PRINT QR CODES"\ - ![print qr codes](https://i.imgur.com/2GAiSTe.png) -4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\ - ![qr codes sheet](https://i.imgur.com/faEqOcd.png) -5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet -6. Choose what type of confirmation do you want costumers to report to merchant after a successful payment\ - ![wordlist](https://i.imgur.com/9aM6NUL.png) - - - Wordlist is the default option: after a successful payment the costumer will receive a word from this list, **sequentially**. Starting in _albatross_ as costumers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\ - ![totp authenticator](https://i.imgur.com/MrJXFxz.png) - - TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\ - ![disable confirmations](https://i.imgur.com/2OFs4yi.png) - - Nothing, disables the need for confirmation of payment, click the "DISABLE CONFIRMATION CODES" diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py deleted file mode 100644 index 1f9dd123..00000000 --- a/lnbits/extensions/offlineshop/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from quart import Blueprint - -from lnbits.db import Database - -db = Database("ext_offlineshop") - -offlineshop_ext: Blueprint = Blueprint( - "offlineshop", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa -from .lnurl import * # noqa diff --git a/lnbits/extensions/offlineshop/config.json b/lnbits/extensions/offlineshop/config.json deleted file mode 100644 index 0dcb1d6b..00000000 --- a/lnbits/extensions/offlineshop/config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "OfflineShop", - "short_description": "Receive payments for products offline!", - "icon": "nature_people", - "contributors": [ - "fiatjaf" - ] -} diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py deleted file mode 100644 index 2ee931cd..00000000 --- a/lnbits/extensions/offlineshop/crud.py +++ /dev/null @@ -1,113 +0,0 @@ -from typing import List, Optional - -from lnbits.db import SQLITE -from . import db -from .wordlists import animals -from .models import Shop, Item - - -async def create_shop(*, wallet_id: str) -> int: - returning = "" if db.type == SQLITE else "RETURNING ID" - method = db.execute if db.type == SQLITE else db.fetchone - - result = await (method)( - f""" - INSERT INTO offlineshop.shops (wallet, wordlist, method) - VALUES (?, ?, 'wordlist') - {returning} - """, - (wallet_id, "\n".join(animals)), - ) - if db.type == SQLITE: - return result._result_proxy.lastrowid - else: - return result[0] - - -async def get_shop(id: int) -> Optional[Shop]: - row = await db.fetchone("SELECT * FROM offlineshop.shops WHERE id = ?", (id,)) - return Shop(**dict(row)) if row else None - - -async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]: - row = await db.fetchone( - "SELECT * FROM offlineshop.shops WHERE wallet = ?", (wallet,) - ) - - if not row: - # create on the fly - ls_id = await create_shop(wallet_id=wallet) - return await get_shop(ls_id) - - return Shop(**dict(row)) if row else None - - -async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]: - await db.execute( - "UPDATE offlineshop.shops SET method = ?, wordlist = ? WHERE id = ?", - (method, wordlist, shop), - ) - return await get_shop(shop) - - -async def add_item( - shop: int, - name: str, - description: str, - image: Optional[str], - price: int, - unit: str, -) -> int: - result = await db.execute( - """ - INSERT INTO offlineshop.items (shop, name, description, image, price, unit) - VALUES (?, ?, ?, ?, ?, ?) - """, - (shop, name, description, image, price, unit), - ) - return result._result_proxy.lastrowid - - -async def update_item( - shop: int, - item_id: int, - name: str, - description: str, - image: Optional[str], - price: int, - unit: str, -) -> int: - await db.execute( - """ - UPDATE offlineshop.items SET - name = ?, - description = ?, - image = ?, - price = ?, - unit = ? - WHERE shop = ? AND id = ? - """, - (name, description, image, price, unit, shop, item_id), - ) - return item_id - - -async def get_item(id: int) -> Optional[Item]: - row = await db.fetchone( - "SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,) - ) - return Item(**dict(row)) if row else None - - -async def get_items(shop: int) -> List[Item]: - rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,)) - return [Item(**dict(row)) for row in rows] - - -async def delete_item_from_shop(shop: int, item_id: int): - await db.execute( - """ - DELETE FROM offlineshop.items WHERE shop = ? AND id = ? - """, - (shop, item_id), - ) diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py deleted file mode 100644 index db2c19cc..00000000 --- a/lnbits/extensions/offlineshop/helpers.py +++ /dev/null @@ -1,17 +0,0 @@ -import base64 -import struct -import hmac -import time - - -def hotp(key, counter, digits=6, digest="sha1"): - key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8)) - counter = struct.pack(">Q", counter) - mac = hmac.new(key, counter, digest).digest() - offset = mac[-1] & 0x0f - binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff - return str(binary)[-digits:].zfill(digits) - - -def totp(key, time_step=30, digits=6, digest="sha1"): - return hotp(key, int(time.time() / time_step), digits, digest) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py deleted file mode 100644 index d99e4cea..00000000 --- a/lnbits/extensions/offlineshop/lnurl.py +++ /dev/null @@ -1,87 +0,0 @@ -import hashlib -from quart import jsonify, url_for, request -from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore - -from lnbits.core.services import create_invoice -from lnbits.utils.exchange_rates import fiat_amount_as_satoshis - -from . import offlineshop_ext -from .crud import get_shop, get_item - - -@offlineshop_ext.route("/lnurl/", methods=["GET"]) -async def lnurl_response(item_id): - item = await get_item(item_id) - if not item: - return jsonify({"status": "ERROR", "reason": "Item not found."}) - - if not item.enabled: - return jsonify({"status": "ERROR", "reason": "Item disabled."}) - - price_msat = ( - await fiat_amount_as_satoshis(item.price, item.unit) - if item.unit != "sat" - else item.price - ) * 1000 - - resp = LnurlPayResponse( - callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True), - min_sendable=price_msat, - max_sendable=price_msat, - metadata=await item.lnurlpay_metadata(), - ) - - return jsonify(resp.dict()) - - -@offlineshop_ext.route("/lnurl/cb/", methods=["GET"]) -async def lnurl_callback(item_id): - item = await get_item(item_id) - if not item: - return jsonify({"status": "ERROR", "reason": "Couldn't find item."}) - - if item.unit == "sat": - min = item.price * 1000 - max = item.price * 1000 - else: - price = await fiat_amount_as_satoshis(item.price, item.unit) - # allow some fluctuation (the fiat price may have changed between the calls) - min = price * 995 - max = price * 1010 - - amount_received = int(request.args.get("amount") or 0) - if amount_received < min: - return jsonify( - LnurlErrorResponse( - reason=f"Amount {amount_received} is smaller than minimum {min}." - ).dict() - ) - elif amount_received > max: - return jsonify( - LnurlErrorResponse( - reason=f"Amount {amount_received} is greater than maximum {max}." - ).dict() - ) - - shop = await get_shop(item.shop) - - try: - payment_hash, payment_request = await create_invoice( - wallet_id=shop.wallet, - amount=int(amount_received / 1000), - memo=item.name, - description_hash=hashlib.sha256( - (await item.lnurlpay_metadata()).encode("utf-8") - ).digest(), - extra={"tag": "offlineshop", "item": item.id}, - ) - except Exception as exc: - return jsonify(LnurlErrorResponse(reason=exc.message).dict()) - - resp = LnurlPayActionResponse( - pr=payment_request, - success_action=item.success_action(shop, payment_hash) if shop.method else None, - routes=[], - ) - - return jsonify(resp.dict()) diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py deleted file mode 100644 index f7c2dfec..00000000 --- a/lnbits/extensions/offlineshop/migrations.py +++ /dev/null @@ -1,29 +0,0 @@ -async def m001_initial(db): - """ - Initial offlineshop tables. - """ - await db.execute( - f""" - CREATE TABLE offlineshop.shops ( - id {db.serial_primary_key}, - wallet TEXT NOT NULL, - method TEXT NOT NULL, - wordlist TEXT - ); - """ - ) - - await db.execute( - f""" - CREATE TABLE offlineshop.items ( - shop INTEGER NOT NULL REFERENCES {db.references_schema}shops (id), - id {db.serial_primary_key}, - name TEXT NOT NULL, - description TEXT NOT NULL, - image TEXT, -- image/png;base64,... - enabled BOOLEAN NOT NULL DEFAULT true, - price INTEGER NOT NULL, - unit TEXT NOT NULL DEFAULT 'sat' - ); - """ - ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py deleted file mode 100644 index eb767cdf..00000000 --- a/lnbits/extensions/offlineshop/models.py +++ /dev/null @@ -1,120 +0,0 @@ -import json -import base64 -import hashlib -from collections import OrderedDict -from quart import url_for -from typing import NamedTuple, Optional, List, Dict -from lnurl import encode as lnurl_encode # type: ignore -from lnurl.types import LnurlPayMetadata # type: ignore -from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore - -from .helpers import totp - -shop_counters: Dict = {} - - -class ShopCounter(object): - fulfilled_payments: OrderedDict - counter: int - - @classmethod - def invoke(cls, shop: "Shop"): - shop_counter = shop_counters.get(shop.id) - if not shop_counter: - shop_counter = cls(wordlist=shop.wordlist.split("\n")) - shop_counters[shop.id] = shop_counter - return shop_counter - - @classmethod - def reset(cls, shop: "Shop"): - shop_counter = cls.invoke(shop) - shop_counter.counter = -1 - shop_counter.wordlist = shop.wordlist.split("\n") - - def __init__(self, wordlist: List[str]): - self.wordlist = wordlist - self.fulfilled_payments = OrderedDict() - self.counter = -1 - - def get_word(self, payment_hash): - if payment_hash in self.fulfilled_payments: - return self.fulfilled_payments[payment_hash] - - # get a new word - self.counter += 1 - word = self.wordlist[self.counter % len(self.wordlist)] - self.fulfilled_payments[payment_hash] = word - - # cleanup confirmation words cache - to_remove = len(self.fulfilled_payments) - 23 - if to_remove > 0: - for i in range(to_remove): - self.fulfilled_payments.popitem(False) - - return word - - -class Shop(NamedTuple): - id: int - wallet: str - method: str - wordlist: str - - @property - def otp_key(self) -> str: - return base64.b32encode( - hashlib.sha256( - ("otpkey" + str(self.id) + self.wallet).encode("ascii"), - ).digest() - ).decode("ascii") - - def get_code(self, payment_hash: str) -> str: - if self.method == "wordlist": - sc = ShopCounter.invoke(self) - return sc.get_word(payment_hash) - elif self.method == "totp": - return totp(self.otp_key) - return "" - - -class Item(NamedTuple): - shop: int - id: int - name: str - description: str - image: str - enabled: bool - price: int - unit: str - - @property - def lnurl(self) -> str: - return lnurl_encode( - url_for("offlineshop.lnurl_response", item_id=self.id, _external=True) - ) - - def values(self): - values = self._asdict() - values["lnurl"] = self.lnurl - return values - - async def lnurlpay_metadata(self) -> LnurlPayMetadata: - metadata = [["text/plain", self.description]] - - if self.image: - metadata.append(self.image.split(":")[1].split(",")) - - return LnurlPayMetadata(json.dumps(metadata)) - - def success_action( - self, shop: Shop, payment_hash: str - ) -> Optional[LnurlPaySuccessAction]: - if not shop.wordlist: - return None - - return UrlAction( - url=url_for( - "offlineshop.confirmation_code", p=payment_hash, _external=True - ), - description="Open to get the confirmation code for your purchase.", - ) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js deleted file mode 100644 index 00e93241..00000000 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ /dev/null @@ -1,220 +0,0 @@ -/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ - -Vue.component(VueQrcode.name, VueQrcode) - -const pica = window.pica() - -const defaultItemData = { - unit: 'sat' -} - -new Vue({ - el: '#vue', - mixins: [windowMixin], - data() { - return { - selectedWallet: null, - confirmationMethod: 'wordlist', - wordlistTainted: false, - offlineshop: { - method: null, - wordlist: [], - items: [] - }, - itemDialog: { - show: false, - data: {...defaultItemData}, - units: ['sat'] - } - } - }, - computed: { - printItems() { - return this.offlineshop.items.filter(({enabled}) => enabled) - } - }, - methods: { - openNewDialog() { - this.itemDialog.show = true - this.itemDialog.data = {...defaultItemData} - }, - openUpdateDialog(itemId) { - this.itemDialog.show = true - let item = this.offlineshop.items.find(item => item.id === itemId) - this.itemDialog.data = item - }, - imageAdded(file) { - let blobURL = URL.createObjectURL(file) - let image = new Image() - image.src = blobURL - image.onload = async () => { - let canvas = document.createElement('canvas') - canvas.setAttribute('width', 100) - canvas.setAttribute('height', 100) - await pica.resize(image, canvas, { - quality: 0, - alpha: true, - unsharpAmount: 95, - unsharpRadius: 0.9, - unsharpThreshold: 70 - }) - this.itemDialog.data.image = canvas.toDataURL() - this.itemDialog = {...this.itemDialog} - } - }, - imageCleared() { - this.itemDialog.data.image = null - this.itemDialog = {...this.itemDialog} - }, - disabledAddItemButton() { - return ( - !this.itemDialog.data.name || - this.itemDialog.data.name.length === 0 || - !this.itemDialog.data.price || - !this.itemDialog.data.description || - !this.itemDialog.data.unit || - this.itemDialog.data.unit.length === 0 - ) - }, - changedWallet(wallet) { - this.selectedWallet = wallet - this.loadShop() - }, - loadShop() { - LNbits.api - .request( - 'GET', - '/offlineshop/api/v1/offlineshop', - this.selectedWallet.inkey - ) - .then(response => { - this.offlineshop = response.data - this.confirmationMethod = response.data.method - this.wordlistTainted = false - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - async setMethod() { - try { - await LNbits.api.request( - 'PUT', - '/offlineshop/api/v1/offlineshop/method', - this.selectedWallet.inkey, - {method: this.confirmationMethod, wordlist: this.offlineshop.wordlist} - ) - } catch (err) { - LNbits.utils.notifyApiError(err) - return - } - - this.$q.notify({ - message: - `Method set to ${this.confirmationMethod}.` + - (this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''), - timeout: 700 - }) - this.loadShop() - }, - async sendItem() { - let {id, name, image, description, price, unit} = this.itemDialog.data - const data = { - name, - description, - image, - price, - unit - } - - try { - if (id) { - await LNbits.api.request( - 'PUT', - '/offlineshop/api/v1/offlineshop/items/' + id, - this.selectedWallet.inkey, - data - ) - } else { - await LNbits.api.request( - 'POST', - '/offlineshop/api/v1/offlineshop/items', - this.selectedWallet.inkey, - data - ) - this.$q.notify({ - message: `Item '${this.itemDialog.data.name}' added.`, - timeout: 700 - }) - } - } catch (err) { - LNbits.utils.notifyApiError(err) - return - } - - this.loadShop() - this.itemDialog.show = false - this.itemDialog.data = {...defaultItemData} - }, - toggleItem(itemId) { - let item = this.offlineshop.items.find(item => item.id === itemId) - item.enabled = !item.enabled - - LNbits.api - .request( - 'PUT', - '/offlineshop/api/v1/offlineshop/items/' + itemId, - this.selectedWallet.inkey, - item - ) - .then(response => { - this.$q.notify({ - message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`, - timeout: 700 - }) - this.offlineshop.items = this.offlineshop.items - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - deleteItem(itemId) { - LNbits.utils - .confirmDialog('Are you sure you want to delete this item?') - .onOk(() => { - LNbits.api - .request( - 'DELETE', - '/offlineshop/api/v1/offlineshop/items/' + itemId, - this.selectedWallet.inkey - ) - .then(response => { - this.$q.notify({ - message: `Item deleted.`, - timeout: 700 - }) - this.offlineshop.items.splice( - this.offlineshop.items.findIndex(item => item.id === itemId), - 1 - ) - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }) - } - }, - created() { - this.selectedWallet = this.g.user.wallets[0] - this.loadShop() - - LNbits.api - .request('GET', '/offlineshop/api/v1/currencies') - .then(response => { - this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]} - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - } -}) diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html deleted file mode 100644 index 1e3bf051..00000000 --- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html +++ /dev/null @@ -1,147 +0,0 @@ - - - -
    -
  1. Register items.
  2. -
  3. - Print QR codes and paste them on your store, your menu, somewhere, - somehow. -
  4. -
  5. - Clients scan the QR codes and get information about the items plus the - price on their phones directly (they must have internet) -
  6. -
  7. - Once they decide to pay, they'll get an invoice on their phones - automatically -
  8. -
  9. - When the payment is confirmed, a confirmation code will be issued for - them. -
  10. -
-

- The confirmation codes are words from a predefined sequential word list. - Each new payment bumps the words sequence by 1. So you can check the - confirmation codes manually by just looking at them. -

-

- For example, if your wordlist is - [apple, banana, coconut] the first purchase will be - apple, the second banana and so on. When it - gets to the end it starts from the beginning again. -

-

Powered by LNURL-pay.

-
-
-
- - - - - - POST -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
Returns 201 OK
-
Curl example
- curl -X GET {{ request.url_root - }}/offlineshop/api/v1/offlineshop/items -H "Content-Type: - application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d - '{"name": <string>, "description": <string>, "image": - <data-uri string>, "price": <integer>, "unit": <"sat" - or "USD">}' - -
-
-
- - - - GET -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- {"id": <integer>, "wallet": <string>, "wordlist": - <string>, "items": [{"id": <integer>, "name": - <string>, "description": <string>, "image": - <string>, "enabled": <boolean>, "price": <integer>, - "unit": <string>, "lnurl": <string>}, ...]}< -
Curl example
- curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - PUT -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
Returns 200 OK
-
Curl example
- curl -X GET {{ request.url_root - }}/offlineshop/api/v1/offlineshop/items/<item_id> -H - "Content-Type: application/json" -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" -d '{"name": <string>, - "description": <string>, "image": <data-uri string>, - "price": <integer>, "unit": <"sat" or "USD">}' - -
-
-
- - - - DELETE -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
Returns 200 OK
-
Curl example
- curl -X GET {{ request.url_root - }}/offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key: - {{ g.user.wallets[0].inkey }}" - -
-
-
-
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html deleted file mode 100644 index 7a3a5125..00000000 --- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html +++ /dev/null @@ -1,335 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - -
-
-
Items
-
-
- Add new item -
-
- {% raw %} - - - - - {% endraw %} -
-
- - - -
-
Wallet Shop
-
- - - - - - -
- Print QR Codes -
-
-
- - - - - - - - - - -
-
- -
-
- - Update Wordlist - - Reset -
-
-
- -
-
-
- - - -
-
- - Set TOTP - -
-
-
- -
-

- Setting this option disables the confirmation code message that - appears in the consumer wallet after a purchase is paid for. It's ok - if the consumer is to be trusted when they claim to have paid. -

- - - Disable Confirmation Codes - -
-
-
-
- -
- - -
- {{SITE_TITLE}} OfflineShop extension -
-
- - - {% include "offlineshop/_api_docs.html" %} - -
-
- - - - -
-
Adding a new item
- - - - - -
- Copy LNURL -
- - - - - - - - - - -
-
- - {% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %} - Item - -
-
- Cancel -
-
-
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - - -{% endblock %} diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/print.html b/lnbits/extensions/offlineshop/templates/offlineshop/print.html deleted file mode 100644 index fff12b4c..00000000 --- a/lnbits/extensions/offlineshop/templates/offlineshop/print.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "print.html" %} {% block page %} {% raw %} -
-
-
{{ item.name }}
- -
{{ item.price }}
-
-
-{% endraw %} {% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py deleted file mode 100644 index 33702f6b..00000000 --- a/lnbits/extensions/offlineshop/views.py +++ /dev/null @@ -1,70 +0,0 @@ -import time -from datetime import datetime -from quart import g, render_template, request -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids -from lnbits.core.models import Payment -from lnbits.core.crud import get_standalone_payment - -from . import offlineshop_ext -from .crud import get_item, get_shop - - -@offlineshop_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("offlineshop/index.html", user=g.user) - - -@offlineshop_ext.route("/print") -async def print_qr_codes(): - items = [] - for item_id in request.args.get("items").split(","): - item = await get_item(item_id) - if item: - items.append( - { - "lnurl": item.lnurl, - "name": item.name, - "price": f"{item.price} {item.unit}", - } - ) - - return await render_template("offlineshop/print.html", items=items) - - -@offlineshop_ext.route("/confirmation") -async def confirmation_code(): - style = "" - - payment_hash = request.args.get("p") - payment: Payment = await get_standalone_payment(payment_hash) - if not payment: - return ( - f"Couldn't find the payment {payment_hash}." + style, - HTTPStatus.NOT_FOUND, - ) - if payment.pending: - return ( - f"Payment {payment_hash} wasn't received yet. Please try again in a minute." - + style, - HTTPStatus.PAYMENT_REQUIRED, - ) - - if payment.time + 60 * 15 < time.time(): - return "too much time has passed." + style - - item = await get_item(payment.extra.get("item")) - shop = await get_shop(item.shop) - - return ( - f""" -[{shop.get_code(payment_hash)}]
-{item.name}
-{item.price} {item.unit}
-{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')} - """ - + style - ) diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py deleted file mode 100644 index ee3631a7..00000000 --- a/lnbits/extensions/offlineshop/views_api.py +++ /dev/null @@ -1,128 +0,0 @@ -from quart import g, jsonify -from http import HTTPStatus -from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore - -from lnbits.decorators import api_check_wallet_key, api_validate_post_request -from lnbits.utils.exchange_rates import currencies - -from . import offlineshop_ext -from .crud import ( - get_or_create_shop_by_wallet, - set_method, - add_item, - update_item, - get_items, - delete_item_from_shop, -) -from .models import ShopCounter - - -@offlineshop_ext.route("/api/v1/currencies", methods=["GET"]) -async def api_list_currencies_available(): - return jsonify(list(currencies.keys())) - - -@offlineshop_ext.route("/api/v1/offlineshop", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_shop_from_wallet(): - shop = await get_or_create_shop_by_wallet(g.wallet.id) - items = await get_items(shop.id) - - try: - return ( - jsonify( - { - **shop._asdict(), - **{ - "otp_key": shop.otp_key, - "items": [item.values() for item in items], - }, - } - ), - HTTPStatus.OK, - ) - except LnurlInvalidUrl: - return ( - jsonify( - { - "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." - } - ), - HTTPStatus.UPGRADE_REQUIRED, - ) - - -@offlineshop_ext.route("/api/v1/offlineshop/items", methods=["POST"]) -@offlineshop_ext.route("/api/v1/offlineshop/items/", methods=["PUT"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "name": {"type": "string", "empty": False, "required": True}, - "description": {"type": "string", "empty": False, "required": True}, - "image": {"type": "string", "required": False, "nullable": True}, - "price": {"type": "number", "required": True}, - "unit": {"type": "string", "required": True}, - } -) -async def api_add_or_update_item(item_id=None): - shop = await get_or_create_shop_by_wallet(g.wallet.id) - if item_id == None: - await add_item( - shop.id, - g.data["name"], - g.data["description"], - g.data.get("image"), - g.data["price"], - g.data["unit"], - ) - return "", HTTPStatus.CREATED - else: - await update_item( - shop.id, - item_id, - g.data["name"], - g.data["description"], - g.data.get("image"), - g.data["price"], - g.data["unit"], - ) - return "", HTTPStatus.OK - - -@offlineshop_ext.route("/api/v1/offlineshop/items/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_delete_item(item_id): - shop = await get_or_create_shop_by_wallet(g.wallet.id) - await delete_item_from_shop(shop.id, item_id) - return "", HTTPStatus.NO_CONTENT - - -@offlineshop_ext.route("/api/v1/offlineshop/method", methods=["PUT"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "method": {"type": "string", "required": True, "nullable": False}, - "wordlist": { - "type": "string", - "empty": True, - "nullable": True, - "required": False, - }, - } -) -async def api_set_method(): - method = g.data["method"] - - wordlist = g.data["wordlist"].split("\n") if g.data["wordlist"] else None - wordlist = [word.strip() for word in wordlist if word.strip()] - - shop = await get_or_create_shop_by_wallet(g.wallet.id) - if not shop: - return "", HTTPStatus.NOT_FOUND - - updated_shop = await set_method(shop.id, method, "\n".join(wordlist)) - if not updated_shop: - return "", HTTPStatus.NOT_FOUND - - ShopCounter.reset(updated_shop) - return "", HTTPStatus.OK diff --git a/lnbits/extensions/offlineshop/wordlists.py b/lnbits/extensions/offlineshop/wordlists.py deleted file mode 100644 index ee3663e3..00000000 --- a/lnbits/extensions/offlineshop/wordlists.py +++ /dev/null @@ -1,28 +0,0 @@ -animals = [ - "albatross", - "bison", - "chicken", - "duck", - "eagle", - "flamingo", - "gorila", - "hamster", - "iguana", - "jaguar", - "koala", - "llama", - "macaroni penguim", - "numbat", - "octopus", - "platypus", - "quetzal", - "rabbit", - "salmon", - "tuna", - "unicorn", - "vulture", - "wolf", - "xenops", - "yak", - "zebra", -] diff --git a/lnbits/extensions/paywall/README.md b/lnbits/extensions/paywall/README.md deleted file mode 100644 index 738485e2..00000000 --- a/lnbits/extensions/paywall/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Paywall - -## Hide content behind a paywall, a user has to pay some amount to access your hidden content - -A Paywall is a way of restricting to content via a purchase or paid subscription. For example to read a determined blog post, or to continue reading further, to access a downloads area, etc... - -## Usage - -1. Create a paywall by clicking "NEW PAYWALL"\ - ![create new paywall](https://i.imgur.com/q0ZIekC.png) -2. Fill the options for your PAYWALL - - select the wallet - - set the link that will be unlocked after a successful payment - - give your paywall a _Title_ - - an optional small description - - and set an amount a user must pay to access the hidden content. Note this is the minimum amount, a user can over pay if they wish - - if _Remember payments_ is checked, a returning paying user won't have to pay again for the same content.\ - ![paywall config](https://i.imgur.com/CBW48F6.png) -3. You can then use your paywall link to secure your awesome content\ - ![paywall link](https://i.imgur.com/hDQmCDf.png) -4. When a user wants to access your hidden content, he can use the minimum amount or increase and click the "_Check icon_" to generate an invoice, user will then be redirected to the content page\ - ![user paywall view](https://i.imgur.com/3pLywkZ.png) diff --git a/lnbits/extensions/paywall/__init__.py b/lnbits/extensions/paywall/__init__.py deleted file mode 100644 index cf9570a1..00000000 --- a/lnbits/extensions/paywall/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_paywall") - -paywall_ext: Blueprint = Blueprint( - "paywall", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/paywall/config.json b/lnbits/extensions/paywall/config.json deleted file mode 100644 index d08ce7ba..00000000 --- a/lnbits/extensions/paywall/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Paywall", - "short_description": "Create paywalls for content", - "icon": "policy", - "contributors": ["eillarra"] -} diff --git a/lnbits/extensions/paywall/crud.py b/lnbits/extensions/paywall/crud.py deleted file mode 100644 index c13aba43..00000000 --- a/lnbits/extensions/paywall/crud.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import List, Optional, Union - -from lnbits.helpers import urlsafe_short_hash - -from . import db -from .models import Paywall - - -async def create_paywall( - *, - wallet_id: str, - url: str, - memo: str, - description: Optional[str] = None, - amount: int = 0, - remembers: bool = True, -) -> Paywall: - paywall_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO paywall.paywalls (id, wallet, url, memo, description, amount, remembers) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - (paywall_id, wallet_id, url, memo, description, amount, int(remembers)), - ) - - paywall = await get_paywall(paywall_id) - assert paywall, "Newly created paywall couldn't be retrieved" - return paywall - - -async def get_paywall(paywall_id: str) -> Optional[Paywall]: - row = await db.fetchone( - "SELECT * FROM paywall.paywalls WHERE id = ?", (paywall_id,) - ) - - return Paywall.from_row(row) if row else None - - -async def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM paywall.paywalls WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [Paywall.from_row(row) for row in rows] - - -async def delete_paywall(paywall_id: str) -> None: - await db.execute("DELETE FROM paywall.paywalls WHERE id = ?", (paywall_id,)) diff --git a/lnbits/extensions/paywall/migrations.py b/lnbits/extensions/paywall/migrations.py deleted file mode 100644 index 8afe58b1..00000000 --- a/lnbits/extensions/paywall/migrations.py +++ /dev/null @@ -1,66 +0,0 @@ -from sqlalchemy.exc import OperationalError # type: ignore - - -async def m001_initial(db): - """ - Initial paywalls table. - """ - await db.execute( - """ - CREATE TABLE paywall.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 """ - + db.timestamp_now - + """ - ); - """ - ) - - -async def m002_redux(db): - """ - Creates an improved paywalls table and migrates the existing data. - """ - await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old") - await db.execute( - """ - CREATE TABLE paywall.paywalls ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - url TEXT NOT NULL, - memo TEXT NOT NULL, - description TEXT NULL, - amount INTEGER DEFAULT 0, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """, - remembers INTEGER DEFAULT 0, - extras TEXT NULL - ); - """ - ) - - for row in [ - list(row) for row in await db.fetchall("SELECT * FROM paywall.paywalls_old") - ]: - await db.execute( - """ - INSERT INTO paywall.paywalls ( - id, - wallet, - url, - memo, - amount, - time - ) - VALUES (?, ?, ?, ?, ?, ?) - """, - (row[0], row[1], row[3], row[4], row[5], row[6]), - ) - - await db.execute("DROP TABLE paywall.paywalls_old") diff --git a/lnbits/extensions/paywall/models.py b/lnbits/extensions/paywall/models.py deleted file mode 100644 index d7f2451d..00000000 --- a/lnbits/extensions/paywall/models.py +++ /dev/null @@ -1,23 +0,0 @@ -import json - -from sqlite3 import Row -from typing import NamedTuple, Optional - - -class Paywall(NamedTuple): - id: str - wallet: str - url: str - memo: str - description: str - amount: int - time: int - remembers: bool - extras: Optional[dict] - - @classmethod - def from_row(cls, row: Row) -> "Paywall": - data = dict(row) - data["remembers"] = bool(data["remembers"]) - data["extras"] = json.loads(data["extras"]) if data["extras"] else None - return cls(**data) diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html deleted file mode 100644 index 1157fa46..00000000 --- a/lnbits/extensions/paywall/templates/paywall/_api_docs.html +++ /dev/null @@ -1,147 +0,0 @@ - - - - - GET /paywall/api/v1/paywalls -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [<paywall_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - POST /paywall/api/v1/paywalls -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
- {"amount": <integer>, "description": <string>, "memo": - <string>, "remembers": <boolean>, "url": - <string>} -
- Returns 201 CREATED (application/json) -
- {"amount": <integer>, "description": <string>, "id": - <string>, "memo": <string>, "remembers": <boolean>, - "time": <int>, "url": <string>, "wallet": - <string>} -
Curl example
- curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url": - <string>, "memo": <string>, "description": <string>, - "amount": <integer>, "remembers": <boolean>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - -
-
-
- - - - POST - /paywall/api/v1/paywalls/<paywall_id>/invoice -
Body (application/json)
- {"amount": <integer>} -
- Returns 201 CREATED (application/json) -
- {"payment_hash": <string>, "payment_request": - <string>} -
Curl example
- curl -X POST {{ request.url_root - }}api/v1/paywalls/<paywall_id>/invoice -d '{"amount": - <integer>}' -H "Content-type: application/json" - -
-
-
- - - - POST - /paywall/api/v1/paywalls/<paywall_id>/check_invoice -
Body (application/json)
- {"payment_hash": <string>} -
- Returns 200 OK (application/json) -
- {"paid": false}
- {"paid": true, "url": <string>, "remembers": - <boolean>} -
Curl example
- curl -X POST {{ request.url_root - }}api/v1/paywalls/<paywall_id>/check_invoice -d - '{"payment_hash": <string>}' -H "Content-type: application/json" - -
-
-
- - - - DELETE - /paywall/api/v1/paywalls/<paywall_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.url_root - }}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - -
-
-
-
diff --git a/lnbits/extensions/paywall/templates/paywall/display.html b/lnbits/extensions/paywall/templates/paywall/display.html deleted file mode 100644 index 7bc7d9b8..00000000 --- a/lnbits/extensions/paywall/templates/paywall/display.html +++ /dev/null @@ -1,162 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
{{ paywall.memo }}
- {% if paywall.description %} -

{{ paywall.description }}

- {% endif %} -
- - - - - -
- - - - - -
- Copy invoice - Cancel -
-
-
-
- -

- You can access the URL behind this paywall:
- {% raw %}{{ redirectUrl }}{% endraw %} -

-
- Open URL -
-
-
-
-
-
-{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/paywall/templates/paywall/index.html b/lnbits/extensions/paywall/templates/paywall/index.html deleted file mode 100644 index 8be3b2fa..00000000 --- a/lnbits/extensions/paywall/templates/paywall/index.html +++ /dev/null @@ -1,312 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New paywall - - - - - -
-
-
Paywalls
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
- -
- - -
- {{SITE_TITLE}} paywall extension -
-
- - - {% include "paywall/_api_docs.html" %} - -
-
- - - - - - - - - - - - - - - - - Remember payments - A succesful payment will be registered in the browser's - storage, so the user doesn't need to pay again to access the - URL. - - - -
- Create paywall - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/paywall/views.py b/lnbits/extensions/paywall/views.py deleted file mode 100644 index 0dcbad2f..00000000 --- a/lnbits/extensions/paywall/views.py +++ /dev/null @@ -1,22 +0,0 @@ -from quart import g, abort, render_template -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import paywall_ext -from .crud import get_paywall - - -@paywall_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("paywall/index.html", user=g.user) - - -@paywall_ext.route("/") -async def display(paywall_id): - paywall = await get_paywall(paywall_id) or abort( - HTTPStatus.NOT_FOUND, "Paywall does not exist." - ) - return await render_template("paywall/display.html", paywall=paywall) diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py deleted file mode 100644 index 45c80af4..00000000 --- a/lnbits/extensions/paywall/views_api.py +++ /dev/null @@ -1,121 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus - -from lnbits.core.crud import get_user, get_wallet -from lnbits.core.services import create_invoice, check_invoice_status -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import 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") -async def api_paywalls(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]), - HTTPStatus.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}, - "description": { - "type": "string", - "empty": True, - "nullable": True, - "required": False, - }, - "amount": {"type": "integer", "min": 0, "required": True}, - "remembers": {"type": "boolean", "required": True}, - } -) -async def api_paywall_create(): - paywall = await create_paywall(wallet_id=g.wallet.id, **g.data) - return jsonify(paywall._asdict()), HTTPStatus.CREATED - - -@paywall_ext.route("/api/v1/paywalls/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_paywall_delete(paywall_id): - paywall = await get_paywall(paywall_id) - - if not paywall: - return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND - - if paywall.wallet != g.wallet.id: - return jsonify({"message": "Not your paywall."}), HTTPStatus.FORBIDDEN - - await delete_paywall(paywall_id) - - return "", HTTPStatus.NO_CONTENT - - -@paywall_ext.route("/api/v1/paywalls//invoice", methods=["POST"]) -@api_validate_post_request( - schema={"amount": {"type": "integer", "min": 1, "required": True}} -) -async def api_paywall_create_invoice(paywall_id): - paywall = await get_paywall(paywall_id) - - if g.data["amount"] < paywall.amount: - return ( - jsonify({"message": f"Minimum amount is {paywall.amount} sat."}), - HTTPStatus.BAD_REQUEST, - ) - - try: - amount = ( - g.data["amount"] if g.data["amount"] > paywall.amount else paywall.amount - ) - payment_hash, payment_request = await create_invoice( - wallet_id=paywall.wallet, - amount=amount, - memo=f"{paywall.memo}", - extra={"tag": "paywall"}, - ) - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - - return ( - jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), - HTTPStatus.CREATED, - ) - - -@paywall_ext.route("/api/v1/paywalls//check_invoice", methods=["POST"]) -@api_validate_post_request( - schema={"payment_hash": {"type": "string", "empty": False, "required": True}} -) -async def api_paywal_check_invoice(paywall_id): - paywall = await get_paywall(paywall_id) - - if not paywall: - return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND - - try: - status = await check_invoice_status(paywall.wallet, g.data["payment_hash"]) - is_paid = not status.pending - except Exception: - return jsonify({"paid": False}), HTTPStatus.OK - - if is_paid: - wallet = await get_wallet(paywall.wallet) - payment = await wallet.get_payment(g.data["payment_hash"]) - await payment.set_pending(False) - - return ( - jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}), - HTTPStatus.OK, - ) - - return jsonify({"paid": False}), HTTPStatus.OK diff --git a/lnbits/extensions/satspay/README.md b/lnbits/extensions/satspay/README.md deleted file mode 100644 index d52547ae..00000000 --- a/lnbits/extensions/satspay/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# SatsPay Server - -## Create onchain and LN charges. Includes webhooks! - -Easilly create invoices that support Lightning Network and on-chain BTC payment. - -1. Create a "NEW CHARGE"\ - ![new charge](https://i.imgur.com/fUl6p74.png) -2. Fill out the invoice fields - - set a descprition for the payment - - the amount in sats - - the time, in minutes, the invoice is valid for, after this period the invoice can't be payed - - set a webhook that will get the transaction details after a successful payment - - set to where the user should redirect after payment - - set the text for the button that will show after payment (not setting this, will display "NONE" in the button) - - select if you want onchain payment, LN payment or both - - depending on what you select you'll have to choose the respective wallets where to receive your payment\ - ![charge form](https://i.imgur.com/F10yRiW.png) -3. The charge will appear on the _Charges_ section\ - ![charges](https://i.imgur.com/zqHpVxc.png) -4. Your costumer/payee will get the payment page - - they can choose to pay on LN\ - ![offchain payment](https://i.imgur.com/4191SMV.png) - - or pay on chain\ - ![onchain payment](https://i.imgur.com/wzLRR5N.png) -5. You can check the state of your charges in LNBits\ - ![invoice state](https://i.imgur.com/JnBd22p.png) diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py deleted file mode 100644 index 4bdaa2b6..00000000 --- a/lnbits/extensions/satspay/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_satspay") - - -satspay_ext: Blueprint = Blueprint( - "satspay", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/satspay/config.json b/lnbits/extensions/satspay/config.json deleted file mode 100644 index b8cd185a..00000000 --- a/lnbits/extensions/satspay/config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "SatsPayServer", - "short_description": "Create onchain and LN charges", - "icon": "payment", - "contributors": [ - "arcbtc" - ] -} diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py deleted file mode 100644 index 56cabdbe..00000000 --- a/lnbits/extensions/satspay/crud.py +++ /dev/null @@ -1,130 +0,0 @@ -from typing import List, Optional, Union - -# from lnbits.db import open_ext_db -from . import db -from .models import Charges - -from lnbits.helpers import urlsafe_short_hash - -from quart import jsonify -import httpx -from lnbits.core.services import create_invoice, check_invoice_status -from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool - - -###############CHARGES########################## - - -async def create_charge( - user: str, - description: str = None, - onchainwallet: Optional[str] = None, - lnbitswallet: Optional[str] = None, - webhook: Optional[str] = None, - completelink: Optional[str] = None, - completelinktext: Optional[str] = "Back to Merchant", - time: Optional[int] = None, - amount: Optional[int] = None, -) -> Charges: - charge_id = urlsafe_short_hash() - if onchainwallet: - wallet = await get_watch_wallet(onchainwallet) - onchain = await get_fresh_address(onchainwallet) - onchainaddress = onchain.address - else: - onchainaddress = None - if lnbitswallet: - payment_hash, payment_request = await create_invoice( - wallet_id=lnbitswallet, amount=amount, memo=charge_id - ) - else: - payment_hash = None - payment_request = None - await db.execute( - """ - INSERT INTO satspay.charges ( - id, - "user", - description, - onchainwallet, - onchainaddress, - lnbitswallet, - payment_request, - payment_hash, - webhook, - completelink, - completelinktext, - time, - amount, - balance - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - charge_id, - user, - description, - onchainwallet, - onchainaddress, - lnbitswallet, - payment_request, - payment_hash, - webhook, - completelink, - completelinktext, - time, - amount, - 0, - ), - ) - return await get_charge(charge_id) - - -async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id) - ) - row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) - return Charges.from_row(row) if row else None - - -async def get_charge(charge_id: str) -> Charges: - row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) - return Charges.from_row(row) if row else None - - -async def get_charges(user: str) -> List[Charges]: - rows = await db.fetchall( - """SELECT * FROM satspay.charges WHERE "user" = ?""", (user,) - ) - return [Charges.from_row(row) for row in rows] - - -async def delete_charge(charge_id: str) -> None: - await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,)) - - -async def check_address_balance(charge_id: str) -> List[Charges]: - charge = await get_charge(charge_id) - if not charge.paid: - if charge.onchainaddress: - mempool = await get_mempool(charge.user) - try: - async with httpx.AsyncClient() as client: - r = await client.get( - mempool.endpoint + "/api/address/" + charge.onchainaddress - ) - respAmount = r.json()["chain_stats"]["funded_txo_sum"] - if respAmount >= charge.balance: - await update_charge(charge_id=charge_id, balance=respAmount) - except Exception: - pass - if charge.lnbitswallet: - invoice_status = await check_invoice_status( - charge.lnbitswallet, charge.payment_hash - ) - if invoice_status.paid: - return await update_charge(charge_id=charge_id, balance=charge.amount) - row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) - return Charges.from_row(row) if row else None diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py deleted file mode 100644 index 87446c80..00000000 --- a/lnbits/extensions/satspay/migrations.py +++ /dev/null @@ -1,28 +0,0 @@ -async def m001_initial(db): - """ - Initial wallet table. - """ - - await db.execute( - """ - CREATE TABLE satspay.charges ( - id TEXT NOT NULL PRIMARY KEY, - "user" TEXT, - description TEXT, - onchainwallet TEXT, - onchainaddress TEXT, - lnbitswallet TEXT, - payment_request TEXT, - payment_hash TEXT, - webhook TEXT, - completelink TEXT, - completelinktext TEXT, - time INTEGER, - amount INTEGER, - balance INTEGER DEFAULT 0, - timestamp TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py deleted file mode 100644 index a7bfa14f..00000000 --- a/lnbits/extensions/satspay/models.py +++ /dev/null @@ -1,39 +0,0 @@ -from sqlite3 import Row -from typing import NamedTuple -import time - - -class Charges(NamedTuple): - id: str - user: str - description: str - onchainwallet: str - onchainaddress: str - lnbitswallet: str - payment_request: str - payment_hash: str - webhook: str - completelink: str - completelinktext: str - time: int - amount: int - balance: int - timestamp: int - - @classmethod - def from_row(cls, row: Row) -> "Charges": - return cls(**dict(row)) - - @property - def time_elapsed(self): - if (self.timestamp + (self.time * 60)) >= time.time(): - return False - else: - return True - - @property - def paid(self): - if self.balance >= self.amount: - return True - else: - return False diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html deleted file mode 100644 index 526af7f3..00000000 --- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html +++ /dev/null @@ -1,171 +0,0 @@ - - -

- SatsPayServer, create Onchain/LN charges.
WARNING: If using with the - WatchOnly extension, we highly reccomend using a fresh extended public Key - specifically for SatsPayServer!
- - Created by, Ben Arc -

-
- - - - - POST /satspay/api/v1/charge -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X POST {{ request.url_root }}api/v1/charge -d - '{"onchainwallet": <string, watchonly_wallet_id>, - "description": <string>, "webhook":<string>, "time": - <integer>, "amount": <integer>, "lnbitswallet": - <string, lnbits_wallet_id>}' -H "Content-type: - application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}" - -
-
-
- - - - PUT - /satspay/api/v1/charge/<charge_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X POST {{ request.url_root }}api/v1/charge/<charge_id> - -d '{"onchainwallet": <string, watchonly_wallet_id>, - "description": <string>, "webhook":<string>, "time": - <integer>, "amount": <integer>, "lnbitswallet": - <string, lnbits_wallet_id>}' -H "Content-type: - application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}" - -
-
-
- - - - - GET - /satspay/api/v1/charge/<charge_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/charge/<charge_id> - -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - GET /satspay/api/v1/charges -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - DELETE - /satspay/api/v1/charge/<charge_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.url_root - }}api/v1/charge/<charge_id> -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - -
-
-
- - - - GET - /satspay/api/v1/charges/balance/<charge_id> -
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<charge_object>, ...] -
Curl example
- curl -X GET {{ request.url_root - }}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
-
-
diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html deleted file mode 100644 index b3386074..00000000 --- a/lnbits/extensions/satspay/templates/satspay/display.html +++ /dev/null @@ -1,318 +0,0 @@ -{% extends "public.html" %} {% block page %} -
- -
-
-
{{ charge.description }}
-
-
-
-
Time elapsed
-
-
-
Charge paid
-
-
- - - - Awaiting payment... - - {% raw %} {{ newTimeLeft }} {% endraw %} - - - -
-
-
-
- Charge ID: {{ charge.id }} -
- {% raw %} Total to pay: {{ charge_amount }}sats
- Amount paid: {{ charge_balance }}

- Amount due: {{ charge_amount - charge_balance }}sats {% endraw %} -
-
- -
-
-
- - - bitcoin onchain payment method not available - - - - pay with lightning - -
-
- - - bitcoin lightning payment method not available - - - - pay onchain - -
-
- -
-
- - -
-
- -
-
- - -
-
-
- Pay this
- lightning-network invoice
-
- - - - - -
- Copy invoice -
-
-
-
-
- - -
-
- -
-
- - -
-
-
- Send {{ charge.amount }}sats
- to this onchain address
-
- - - - - -
- Copy address -
-
-
-
-
-
-
- -{% endblock %} {% block scripts %} - - - -{% endblock %} diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html deleted file mode 100644 index f3566c7c..00000000 --- a/lnbits/extensions/satspay/templates/satspay/index.html +++ /dev/null @@ -1,555 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - {% raw %} - New charge - - - - - - -
-
-
Charges
-
- -
- - - - Export to CSV -
-
- - - - - {% endraw %} - -
-
-
- -
- - -
- {{SITE_TITLE}} satspay Extension -
-
- - - {% include "satspay/_api_docs.html" %} - -
-
- - - - - - - - - - - - - - - - - - -
-
-
- -
-
- - - Watch-Only extension MUST be activated and have a wallet - - -
-
-
-
- -
-
-
- -
- -
- - - -
- Create Charge - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - - - -{% endblock %} diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py deleted file mode 100644 index 2c99a925..00000000 --- a/lnbits/extensions/satspay/views.py +++ /dev/null @@ -1,22 +0,0 @@ -from quart import g, abort, render_template, jsonify -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import satspay_ext -from .crud import get_charge - - -@satspay_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("satspay/index.html", user=g.user) - - -@satspay_ext.route("/") -async def display(charge_id): - charge = await get_charge(charge_id) or abort( - HTTPStatus.NOT_FOUND, "Charge link does not exist." - ) - return await render_template("satspay/display.html", charge=charge) diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py deleted file mode 100644 index 9440312a..00000000 --- a/lnbits/extensions/satspay/views_api.py +++ /dev/null @@ -1,157 +0,0 @@ -import hashlib -from quart import g, jsonify, url_for -from http import HTTPStatus -import httpx - - -from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from lnbits.extensions.satspay import satspay_ext -from .crud import ( - create_charge, - update_charge, - get_charge, - get_charges, - delete_charge, - check_address_balance, -) - -#############################CHARGES########################## - - -@satspay_ext.route("/api/v1/charge", methods=["POST"]) -@satspay_ext.route("/api/v1/charge/", methods=["PUT"]) -@api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "onchainwallet": {"type": "string"}, - "lnbitswallet": {"type": "string"}, - "description": {"type": "string", "empty": False, "required": True}, - "webhook": {"type": "string"}, - "completelink": {"type": "string"}, - "completelinktext": {"type": "string"}, - "time": {"type": "integer", "min": 1, "required": True}, - "amount": {"type": "integer", "min": 1, "required": True}, - } -) -async def api_charge_create_or_update(charge_id=None): - if not charge_id: - charge = await create_charge(user=g.wallet.user, **g.data) - return jsonify(charge._asdict()), HTTPStatus.CREATED - else: - charge = await update_charge(charge_id=charge_id, **g.data) - return jsonify(charge._asdict()), HTTPStatus.OK - - -@satspay_ext.route("/api/v1/charges", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_charges_retrieve(): - try: - return ( - jsonify( - [ - { - **charge._asdict(), - **{"time_elapsed": charge.time_elapsed}, - **{"paid": charge.paid}, - } - for charge in await get_charges(g.wallet.user) - ] - ), - HTTPStatus.OK, - ) - except: - return "" - - -@satspay_ext.route("/api/v1/charge/", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_charge_retrieve(charge_id): - charge = await get_charge(charge_id) - - if not charge: - return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND - - return ( - jsonify( - { - **charge._asdict(), - **{"time_elapsed": charge.time_elapsed}, - **{"paid": charge.paid}, - } - ), - HTTPStatus.OK, - ) - - -@satspay_ext.route("/api/v1/charge/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_charge_delete(charge_id): - charge = await get_charge(charge_id) - - if not charge: - return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND - - await delete_charge(charge_id) - - return "", HTTPStatus.NO_CONTENT - - -#############################BALANCE########################## - - -@satspay_ext.route("/api/v1/charges/balance/", methods=["GET"]) -async def api_charges_balance(charge_id): - - charge = await check_address_balance(charge_id) - - if not charge: - return jsonify({"message": "charge does not exist"}), HTTPStatus.NOT_FOUND - if charge.paid and charge.webhook: - async with httpx.AsyncClient() as client: - try: - r = await client.post( - charge.webhook, - json={ - "id": charge.id, - "description": charge.description, - "onchainaddress": charge.onchainaddress, - "payment_request": charge.payment_request, - "payment_hash": charge.payment_hash, - "time": charge.time, - "amount": charge.amount, - "balance": charge.balance, - "paid": charge.paid, - "timestamp": charge.timestamp, - "completelink": charge.completelink, - }, - timeout=40, - ) - except AssertionError: - charge.webhook = None - return jsonify(charge._asdict()), HTTPStatus.OK - - -#############################MEMPOOL########################## - - -@satspay_ext.route("/api/v1/mempool", methods=["PUT"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "endpoint": {"type": "string", "empty": False, "required": True}, - } -) -async def api_update_mempool(): - mempool = await update_mempool(user=g.wallet.user, **g.data) - return jsonify(mempool._asdict()), HTTPStatus.OK - - -@satspay_ext.route("/api/v1/mempool", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_get_mempool(): - mempool = await get_mempool(g.wallet.user) - if not mempool: - mempool = await create_mempool(user=g.wallet.user) - return jsonify(mempool._asdict()), HTTPStatus.OK diff --git a/lnbits/extensions/splitpayments/README.md b/lnbits/extensions/splitpayments/README.md deleted file mode 100644 index 04576a57..00000000 --- a/lnbits/extensions/splitpayments/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Split Payments - -## Have payments split between multiple wallets - -LNBits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever. - -## Usage - -1. After enabling the extension, choose the source wallet that will receive and distribute the Payments - -![choose wallet](https://i.imgur.com/nPQudqL.png) - -2. Add the wallet or wallets info to split payments to - -![split wallets](https://i.imgur.com/5hCNWpg.png) - get the wallet id, or an invoice key from a different wallet. It can be a completely different user as long as it's under the same LNbits instance/domain. You can get the wallet information on the API Info section on every wallet page\ - ![wallet info](https://i.imgur.com/betqflC.png) - set a wallet _Alias_ for your own identification\ - -- set how much, in percentage, this wallet will receive from every payment sent to the source wallets - -3. When done, click "SAVE TARGETS" to make the splits effective - -4. You can have several wallets to split to, as long as the sum of the percentages is under or equal to 100% - -5. When the source wallet receives a payment, the extension will automatically split the corresponding values to every wallet\ - - on receiving a 20 sats payment\ - ![get 20 sats payment](https://i.imgur.com/BKp0xvy.png) - - source wallet gets 18 sats\ - ![source wallet](https://i.imgur.com/GCxDZ5s.png) - - Ben's wallet (the wallet from the example) instantly, and feeless, gets the corresponding 10%, or 2 sats\ - ![ben wallet](https://i.imgur.com/MfsccNa.png) - -## Sponsored by - -[![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/) diff --git a/lnbits/extensions/splitpayments/__init__.py b/lnbits/extensions/splitpayments/__init__.py deleted file mode 100644 index 2cd2d7c6..00000000 --- a/lnbits/extensions/splitpayments/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from quart import Blueprint - -from lnbits.db import Database - -db = Database("ext_splitpayments") - -splitpayments_ext: Blueprint = Blueprint( - "splitpayments", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa -from .tasks import register_listeners - -from lnbits.tasks import record_async - -splitpayments_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/splitpayments/config.json b/lnbits/extensions/splitpayments/config.json deleted file mode 100644 index 5d084c70..00000000 --- a/lnbits/extensions/splitpayments/config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "SplitPayments", - "short_description": "Split incoming payments across wallets", - "icon": "call_split", - "contributors": [ - "fiatjaf", - "cryptograffiti" - ] -} diff --git a/lnbits/extensions/splitpayments/crud.py b/lnbits/extensions/splitpayments/crud.py deleted file mode 100644 index ef10add4..00000000 --- a/lnbits/extensions/splitpayments/crud.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import List - -from . import db -from .models import Target - - -async def get_targets(source_wallet: str) -> List[Target]: - rows = await db.fetchall( - "SELECT * FROM splitpayments.targets WHERE source = ?", (source_wallet,) - ) - return [Target(**dict(row)) for row in rows] - - -async def set_targets(source_wallet: str, targets: List[Target]): - async with db.connect() as conn: - await conn.execute( - "DELETE FROM splitpayments.targets WHERE source = ?", (source_wallet,) - ) - for target in targets: - await conn.execute( - """ - INSERT INTO splitpayments.targets - (source, wallet, percent, alias) - VALUES (?, ?, ?, ?) - """, - (source_wallet, target.wallet, target.percent, target.alias), - ) diff --git a/lnbits/extensions/splitpayments/migrations.py b/lnbits/extensions/splitpayments/migrations.py deleted file mode 100644 index 735afc6c..00000000 --- a/lnbits/extensions/splitpayments/migrations.py +++ /dev/null @@ -1,16 +0,0 @@ -async def m001_initial(db): - """ - Initial split payment table. - """ - await db.execute( - """ - CREATE TABLE splitpayments.targets ( - wallet TEXT NOT NULL, - source TEXT NOT NULL, - percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100), - alias TEXT, - - UNIQUE (source, wallet) - ); - """ - ) diff --git a/lnbits/extensions/splitpayments/models.py b/lnbits/extensions/splitpayments/models.py deleted file mode 100644 index 17578f87..00000000 --- a/lnbits/extensions/splitpayments/models.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import NamedTuple - - -class Target(NamedTuple): - wallet: str - source: str - percent: int - alias: str diff --git a/lnbits/extensions/splitpayments/static/js/index.js b/lnbits/extensions/splitpayments/static/js/index.js deleted file mode 100644 index d9750bef..00000000 --- a/lnbits/extensions/splitpayments/static/js/index.js +++ /dev/null @@ -1,143 +0,0 @@ -/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ - -Vue.component(VueQrcode.name, VueQrcode) - -function hashTargets(targets) { - return targets - .filter(isTargetComplete) - .map(({wallet, percent, alias}) => `${wallet}${percent}${alias}`) - .join('') -} - -function isTargetComplete(target) { - return target.wallet && target.wallet.trim() !== '' && target.percent > 0 -} - -new Vue({ - el: '#vue', - mixins: [windowMixin], - data() { - return { - selectedWallet: null, - currentHash: '', // a string that must match if the edit data is unchanged - targets: [] - } - }, - computed: { - isDirty() { - return hashTargets(this.targets) !== this.currentHash - } - }, - methods: { - clearTargets() { - this.targets = [{}] - this.$q.notify({ - message: - 'Cleared the form, but not saved. You must click to save manually.', - timeout: 500 - }) - }, - getTargets() { - LNbits.api - .request( - 'GET', - '/splitpayments/api/v1/targets', - this.selectedWallet.adminkey - ) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - .then(response => { - this.currentHash = hashTargets(response.data) - this.targets = response.data.concat({}) - }) - }, - changedWallet(wallet) { - this.selectedWallet = wallet - this.getTargets() - }, - targetChanged(isPercent, index) { - // fix percent min and max range - if (isPercent) { - if (this.targets[index].percent > 100) this.targets[index].percent = 100 - if (this.targets[index].percent < 0) this.targets[index].percent = 0 - } - - // remove empty lines (except last) - if (this.targets.length >= 2) { - for (let i = this.targets.length - 2; i >= 0; i--) { - let target = this.targets[i] - if ( - (!target.wallet || target.wallet.trim() === '') && - (!target.alias || target.alias.trim() === '') && - !target.percent - ) { - this.targets.splice(i, 1) - } - } - } - - // add a line at the end if the last one is filled - let last = this.targets[this.targets.length - 1] - if (last.wallet && last.wallet.trim() !== '' && last.percent > 0) { - this.targets.push({}) - } - - // sum of all percents - let currentTotal = this.targets.reduce( - (acc, target) => acc + (target.percent || 0), - 0 - ) - - // remove last (unfilled) line if the percent is already 100 - if (currentTotal >= 100) { - let last = this.targets[this.targets.length - 1] - if ( - (!last.wallet || last.wallet.trim() === '') && - (!last.alias || last.alias.trim() === '') && - !last.percent - ) { - this.targets = this.targets.slice(0, -1) - } - } - - // adjust percents of other lines (not this one) - if (currentTotal > 100 && isPercent) { - let diff = (currentTotal - 100) / (100 - this.targets[index].percent) - this.targets.forEach((target, t) => { - if (t !== index) target.percent -= Math.round(diff * target.percent) - }) - } - - // overwrite so changes appear - this.targets = this.targets - }, - saveTargets() { - LNbits.api - .request( - 'PUT', - '/splitpayments/api/v1/targets', - this.selectedWallet.adminkey, - { - targets: this.targets - .filter(isTargetComplete) - .map(({wallet, percent, alias}) => ({wallet, percent, alias})) - } - ) - .then(response => { - this.$q.notify({ - message: 'Split payments targets set.', - timeout: 700 - }) - this.getTargets() - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - } - }, - created() { - this.selectedWallet = this.g.user.wallets[0] - this.getTargets() - } -}) diff --git a/lnbits/extensions/splitpayments/tasks.py b/lnbits/extensions/splitpayments/tasks.py deleted file mode 100644 index 50057d9f..00000000 --- a/lnbits/extensions/splitpayments/tasks.py +++ /dev/null @@ -1,77 +0,0 @@ -import json -import trio - -from lnbits.core.models import Payment -from lnbits.core.crud import create_payment -from lnbits.core import db as core_db -from lnbits.tasks import register_invoice_listener, internal_invoice_paid -from lnbits.helpers import urlsafe_short_hash - -from .crud import get_targets - - -async def register_listeners(): - invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) - register_invoice_listener(invoice_paid_chan_send) - await wait_for_paid_invoices(invoice_paid_chan_recv) - - -async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): - async for payment in invoice_paid_chan: - await on_invoice_paid(payment) - - -async def on_invoice_paid(payment: Payment) -> None: - if "splitpayments" == payment.extra.get("tag") or payment.extra.get("splitted"): - # already splitted, ignore - return - - # now we make some special internal transfers (from no one to the receiver) - targets = await get_targets(payment.wallet_id) - transfers = [ - (target.wallet, int(target.percent * payment.amount / 100)) - for target in targets - ] - transfers = [(wallet, amount) for wallet, amount in transfers if amount > 0] - amount_left = payment.amount - sum([amount for _, amount in transfers]) - - if amount_left < 0: - print("splitpayments failure: amount_left is negative.", payment.payment_hash) - return - - if not targets: - return - - # mark the original payment with one extra key, "splitted" - # (this prevents us from doing this process again and it's informative) - # and reduce it by the amount we're going to send to the producer - await core_db.execute( - """ - UPDATE apipayments - SET extra = ?, amount = ? - WHERE hash = ? - AND checking_id NOT LIKE 'internal_%' - """, - ( - json.dumps(dict(**payment.extra, splitted=True)), - amount_left, - payment.payment_hash, - ), - ) - - # perform the internal transfer using the same payment_hash - for wallet, amount in transfers: - internal_checking_id = f"internal_{urlsafe_short_hash()}" - await create_payment( - wallet_id=wallet, - checking_id=internal_checking_id, - payment_request="", - payment_hash=payment.payment_hash, - amount=amount, - memo=payment.memo, - pending=False, - extra={"tag": "splitpayments"}, - ) - - # manually send this for now - await internal_invoice_paid.send(internal_checking_id) diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html deleted file mode 100644 index e92fac96..00000000 --- a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html +++ /dev/null @@ -1,90 +0,0 @@ - - - -

- Add some wallets to the list of "Target Wallets", each with an - associated percent. After saving, every time any payment - arrives at the "Source Wallet" that payment will be split with the - target wallets according to their percent. -

-

This is valid for every payment, doesn't matter how it was created.

-

Target wallets can be any wallet from this same LNbits instance.

-

- To remove a wallet from the targets list, just erase its fields and - save. To remove all, click "Clear" then save. -

-
-
-
- - - - - - GET - /splitpayments/api/v1/targets -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [{"wallet": <wallet id>, "alias": <chosen name for this - wallet>, "percent": <number between 1 and 100>}, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - PUT - /splitpayments/api/v1/targets -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
-
Curl example
- curl -X PUT {{ request.url_root }}api/v1/splitpayments/targets -H - "X-Api-Key: {{ g.user.wallets[0].adminkey }}" -H 'Content-Type: - application/json' -d '{"targets": [{"wallet": <wallet id or invoice - key>, "alias": <name to identify this>, "percent": <number - between 1 and 100>}, ...]}' - -
-
-
-
diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/index.html b/lnbits/extensions/splitpayments/templates/splitpayments/index.html deleted file mode 100644 index 1aae4e33..00000000 --- a/lnbits/extensions/splitpayments/templates/splitpayments/index.html +++ /dev/null @@ -1,100 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - - - - - - - - - -
-
Target Wallets
-
- - -
- - - -
- - - - - Clear - - - - - - Save Targets - - - -
-
-
-
- -
- - -
- {{SITE_TITLE}} SplitPayments extension -
-
- - - {% include "splitpayments/_api_docs.html" %} - -
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/splitpayments/views.py b/lnbits/extensions/splitpayments/views.py deleted file mode 100644 index acded737..00000000 --- a/lnbits/extensions/splitpayments/views.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import g, render_template - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import splitpayments_ext - - -@splitpayments_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("splitpayments/index.html", user=g.user) diff --git a/lnbits/extensions/splitpayments/views_api.py b/lnbits/extensions/splitpayments/views_api.py deleted file mode 100644 index e0fe475e..00000000 --- a/lnbits/extensions/splitpayments/views_api.py +++ /dev/null @@ -1,70 +0,0 @@ -from quart import g, jsonify -from http import HTTPStatus - -from lnbits.decorators import api_check_wallet_key, api_validate_post_request -from lnbits.core.crud import get_wallet, get_wallet_for_key - -from . import splitpayments_ext -from .crud import get_targets, set_targets -from .models import Target - - -@splitpayments_ext.route("/api/v1/targets", methods=["GET"]) -@api_check_wallet_key("admin") -async def api_targets_get(): - targets = await get_targets(g.wallet.id) - return jsonify([target._asdict() for target in targets] or []) - - -@splitpayments_ext.route("/api/v1/targets", methods=["PUT"]) -@api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "targets": { - "type": "list", - "schema": { - "type": "dict", - "schema": { - "wallet": {"type": "string"}, - "alias": {"type": "string"}, - "percent": {"type": "integer"}, - }, - }, - } - } -) -async def api_targets_set(): - targets = [] - - for entry in g.data["targets"]: - wallet = await get_wallet(entry["wallet"]) - if not wallet: - wallet = await get_wallet_for_key(entry["wallet"], "invoice") - if not wallet: - return ( - jsonify({"message": f"Invalid wallet '{entry['wallet']}'."}), - HTTPStatus.BAD_REQUEST, - ) - - if wallet.id == g.wallet.id: - return ( - jsonify({"message": "Can't split to itself."}), - HTTPStatus.BAD_REQUEST, - ) - - if entry["percent"] < 0: - return ( - jsonify({"message": f"Invalid percent '{entry['percent']}'."}), - HTTPStatus.BAD_REQUEST, - ) - - targets.append( - Target(wallet.id, g.wallet.id, entry["percent"], entry["alias"] or "") - ) - - percent_sum = sum([target.percent for target in targets]) - if percent_sum > 100: - return jsonify({"message": "Splitting over 100%."}), HTTPStatus.BAD_REQUEST - - await set_targets(g.wallet.id, targets) - return "", HTTPStatus.OK diff --git a/lnbits/extensions/streamalerts/README.md b/lnbits/extensions/streamalerts/README.md deleted file mode 100644 index 726ffe76..00000000 --- a/lnbits/extensions/streamalerts/README.md +++ /dev/null @@ -1,39 +0,0 @@ -

Stream Alerts

-

Integrate Bitcoin Donations into your livestream alerts

-The StreamAlerts extension allows you to integrate Bitcoin Lightning (and on-chain) paymnents in to your existing Streamlabs alerts! - -![image](https://user-images.githubusercontent.com/28876473/127759038-aceb2503-6cff-4061-8b81-c769438ebcaa.png) - -

How to set it up

- -At the moment, the only service that has an open API to work with is Streamlabs, so this setup requires linking your Twitch/YouTube/Facebook account to Streamlabs. - -1. Log into [Streamlabs](https://streamlabs.com/login?r=https://streamlabs.com/dashboard). -1. Navigate to the API settings page to register an App: -![image](https://user-images.githubusercontent.com/28876473/127759145-710d53b6-3c19-4815-812a-9a6279d1b8bb.png) -![image](https://user-images.githubusercontent.com/28876473/127759182-da8a27cb-bb59-48fa-868e-c8892080ae98.png) -![image](https://user-images.githubusercontent.com/28876473/127759201-7c28e9f1-6286-42be-a38e-1c377a86976b.png) -1. Fill out the form with anything it will accept as valid. Most fields can be gibberish, as the application is not supposed to ever move past the "testing" stage and is for your personal use only. -In the "Whitelist Users" field, input the username of a Twitch account you control. While this feature is *technically* limited to Twitch, you can use the alerts overlay for donations on YouTube and Facebook as well. -For now, simply set the "Redirect URI" to `http://localhost`, you will change this soon. -Then, hit create: -![image](https://user-images.githubusercontent.com/28876473/127759264-ae91539a-5694-4096-a478-80eb02b7b594.png) -1. In LNbits, enable the Stream Alerts extension and optionally the SatsPayServer (to observe donations directly) and Watch Only (to accept on-chain donations) extenions: -![image](https://user-images.githubusercontent.com/28876473/127759486-0e3420c2-c498-4bf9-932e-0abfa17bd478.png) -1. Create a "NEW SERVICE" using the button. Fill in all the information (you get your Client ID and Secret from the Streamlabs App page): -![image](https://user-images.githubusercontent.com/28876473/127759512-8e8b4e90-2a64-422a-bf0a-5508d0630bed.png) -![image](https://user-images.githubusercontent.com/28876473/127759526-7f2a4980-39ea-4e58-8af0-c9fb381e5524.png) -1. Right-click and copy the "Redirect URI for Streamlabs" link (you might have to refresh the page for the text to turn into a link) and input it into the "Redirect URI" field for your Streamelements App, and hit "Save Settings": -![image](https://user-images.githubusercontent.com/28876473/127759570-52d34c07-6857-467b-bcb3-54e10679aedb.png) -![image](https://user-images.githubusercontent.com/28876473/127759604-b3c8270b-bd02-44df-a525-9d85af337d14.png) -1. You can now authenticate your app on LNbits by clicking on this button and following the instructions. Make sure to log in with the Twitch account you entered in the "Whitelist Users" field: -![image](https://user-images.githubusercontent.com/28876473/127759642-a3787a6a-3cab-4c44-a2d4-ab45fbbe3fab.png) -![image](https://user-images.githubusercontent.com/28876473/127759681-7289e7f6-0ff1-4988-944f-484040f6b9c7.png) -If everything worked correctly, you should now be redirected back to LNbits. When scrolling all the way right, you should now see that the service has been authenticated: -![image](https://user-images.githubusercontent.com/28876473/127759715-7e839261-d505-4e07-a0e4-f347f114149f.png) -You can now share the link to your donations page, which you can get here: -![image](https://user-images.githubusercontent.com/28876473/127759730-8dd11e61-0186-4935-b1ed-b66d35b05043.png) -![image](https://user-images.githubusercontent.com/28876473/127759747-67d3033f-6ef1-4033-b9b1-51b87189ff8b.png) -Of course, this has to be available publicly on the internet (or, depending on your viewers' technical ability, over Tor). -When your viewers donate to you, these donations will now show up in your Streamlabs donation feed, as well as your alerts overlay (if it is configured to include donations). -

CONGRATS! Let the sats flow!

diff --git a/lnbits/extensions/streamalerts/__init__.py b/lnbits/extensions/streamalerts/__init__.py deleted file mode 100644 index 72f0ae7c..00000000 --- a/lnbits/extensions/streamalerts/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_streamalerts") - -streamalerts_ext: Blueprint = Blueprint( - "streamalerts", __name__, static_folder="static", template_folder="templates" -) - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/streamalerts/config.json b/lnbits/extensions/streamalerts/config.json deleted file mode 100644 index 2fbcc55e..00000000 --- a/lnbits/extensions/streamalerts/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Stream Alerts", - "short_description": "Integrate Bitcoin donations into your stream alerts!", - "icon": "notifications_active", - "contributors": ["Fittiboy"] -} diff --git a/lnbits/extensions/streamalerts/crud.py b/lnbits/extensions/streamalerts/crud.py deleted file mode 100644 index cbd9d3b0..00000000 --- a/lnbits/extensions/streamalerts/crud.py +++ /dev/null @@ -1,297 +0,0 @@ -from . import db -from .models import Donation, Service - -from ..satspay.crud import delete_charge # type: ignore - -import httpx - -from http import HTTPStatus -from quart import jsonify - -from typing import Optional - -from lnbits.db import SQLITE -from lnbits.helpers import urlsafe_short_hash -from lnbits.core.crud import get_wallet - - -async def get_service_redirect_uri(request, service_id): - """Return the service's redirect URI, to be given to the third party API""" - uri_base = request.scheme + "://" - uri_base += request.headers["Host"] + "/streamalerts/api/v1" - redirect_uri = uri_base + f"/authenticate/{service_id}" - return redirect_uri - - -async def get_charge_details(service_id): - """Return the default details for a satspay charge - - These might be different depending for services implemented in the future. - """ - details = { - "time": 1440, - } - service = await get_service(service_id) - wallet_id = service.wallet - wallet = await get_wallet(wallet_id) - user = wallet.user - details["user"] = user - details["lnbitswallet"] = wallet_id - details["onchainwallet"] = service.onchain - return details - - -async def create_donation( - id: str, - wallet: str, - cur_code: str, - sats: int, - amount: float, - service: int, - name: str = "Anonymous", - message: str = "", - posted: bool = False, -) -> Donation: - """Create a new Donation""" - await db.execute( - """ - INSERT INTO streamalerts.Donations ( - id, - wallet, - name, - message, - cur_code, - sats, - amount, - service, - posted - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - (id, wallet, name, message, cur_code, sats, amount, service, posted), - ) - - donation = await get_donation(id) - assert donation, "Newly created donation couldn't be retrieved" - return donation - - -async def post_donation(donation_id: str) -> tuple: - """Post donations to their respective third party APIs - - If the donation has already been posted, it will not be posted again. - """ - donation = await get_donation(donation_id) - if not donation: - return (jsonify({"message": "Donation not found!"}), HTTPStatus.BAD_REQUEST) - if donation.posted: - return ( - jsonify({"message": "Donation has already been posted!"}), - HTTPStatus.BAD_REQUEST, - ) - service = await get_service(donation.service) - assert service, "Couldn't fetch service to donate to" - - if service.servicename == "Streamlabs": - url = "https://streamlabs.com/api/v1.0/donations" - data = { - "name": donation.name[:25], - "message": donation.message[:255], - "identifier": "LNbits", - "amount": donation.amount, - "currency": donation.cur_code.upper(), - "access_token": service.token, - } - async with httpx.AsyncClient() as client: - response = await client.post(url, data=data) - print(response.json()) - status = [s for s in list(HTTPStatus) if s == response.status_code][0] - elif service.servicename == "StreamElements": - return ( - jsonify({"message": "StreamElements not yet supported!"}), - HTTPStatus.BAD_REQUEST, - ) - else: - return (jsonify({"message": "Unsopported servicename"}), HTTPStatus.BAD_REQUEST) - await db.execute( - "UPDATE streamalerts.Donations SET posted = 1 WHERE id = ?", (donation_id,) - ) - return (jsonify(response.json()), status) - - -async def create_service( - twitchuser: str, - client_id: str, - client_secret: str, - wallet: str, - servicename: str, - state: str = None, - onchain: str = None, -) -> Service: - """Create a new Service""" - - returning = "" if db.type == SQLITE else "RETURNING ID" - method = db.execute if db.type == SQLITE else db.fetchone - - result = await (method)( - f""" - INSERT INTO streamalerts.Services ( - twitchuser, - client_id, - client_secret, - wallet, - servicename, - authenticated, - state, - onchain - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - {returning} - """, - ( - twitchuser, - client_id, - client_secret, - wallet, - servicename, - False, - urlsafe_short_hash(), - onchain, - ), - ) - if db.type == SQLITE: - service_id = result._result_proxy.lastrowid - else: - service_id = result[0] - - service = await get_service(service_id) - assert service - return service - - -async def get_service(service_id: int, by_state: str = None) -> Optional[Service]: - """Return a service either by ID or, available, by state - - Each Service's donation page is reached through its "state" hash - instead of the ID, preventing accidental payments to the wrong - streamer via typos like 2 -> 3. - """ - if by_state: - row = await db.fetchone( - "SELECT * FROM streamalerts.Services WHERE state = ?", (by_state,) - ) - else: - row = await db.fetchone( - "SELECT * FROM streamalerts.Services WHERE id = ?", (service_id,) - ) - return Service.from_row(row) if row else None - - -async def get_services(wallet_id: str) -> Optional[list]: - """Return all services belonging assigned to the wallet_id""" - rows = await db.fetchall( - "SELECT * FROM streamalerts.Services WHERE wallet = ?", (wallet_id,) - ) - return [Service.from_row(row) for row in rows] if rows else None - - -async def authenticate_service(service_id, code, redirect_uri): - """Use authentication code from third party API to retreive access token""" - # The API token is passed in the querystring as 'code' - service = await get_service(service_id) - wallet = await get_wallet(service.wallet) - user = wallet.user - url = "https://streamlabs.com/api/v1.0/token" - data = { - "grant_type": "authorization_code", - "code": code, - "client_id": service.client_id, - "client_secret": service.client_secret, - "redirect_uri": redirect_uri, - } - print(data) - async with httpx.AsyncClient() as client: - response = (await client.post(url, data=data)).json() - print(response) - token = response["access_token"] - success = await service_add_token(service_id, token) - return f"/streamalerts/?usr={user}", success - - -async def service_add_token(service_id, token): - """Add access token to its corresponding Service - - This also sets authenticated = 1 to make sure the token - is not overwritten. - Tokens for Streamlabs never need to be refreshed. - """ - if (await get_service(service_id)).authenticated: - return False - await db.execute( - "UPDATE streamalerts.Services SET authenticated = 1, token = ? where id = ?", - ( - token, - service_id, - ), - ) - return True - - -async def delete_service(service_id: int) -> None: - """Delete a Service and all corresponding Donations""" - await db.execute("DELETE FROM streamalerts.Services WHERE id = ?", (service_id,)) - rows = await db.fetchall( - "SELECT * FROM streamalerts.Donations WHERE service = ?", (service_id,) - ) - for row in rows: - await delete_donation(row["id"]) - - -async def get_donation(donation_id: str) -> Optional[Donation]: - """Return a Donation""" - row = await db.fetchone( - "SELECT * FROM streamalerts.Donations WHERE id = ?", (donation_id,) - ) - return Donation.from_row(row) if row else None - - -async def get_donations(wallet_id: str) -> Optional[list]: - """Return all streamalerts.Donations assigned to wallet_id""" - rows = await db.fetchall( - "SELECT * FROM streamalerts.Donations WHERE wallet = ?", (wallet_id,) - ) - return [Donation.from_row(row) for row in rows] if rows else None - - -async def delete_donation(donation_id: str) -> None: - """Delete a Donation and its corresponding statspay charge""" - await db.execute("DELETE FROM streamalerts.Donations WHERE id = ?", (donation_id,)) - await delete_charge(donation_id) - - -async def update_donation(donation_id: str, **kwargs) -> Donation: - """Update a Donation""" - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE streamalerts.Donations SET {q} WHERE id = ?", - (*kwargs.values(), donation_id), - ) - row = await db.fetchone( - "SELECT * FROM streamalerts.Donations WHERE id = ?", (donation_id,) - ) - assert row, "Newly updated donation couldn't be retrieved" - return Donation(**row) - - -async def update_service(service_id: str, **kwargs) -> Service: - """Update a service""" - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE streamalerts.Services SET {q} WHERE id = ?", - (*kwargs.values(), service_id), - ) - row = await db.fetchone( - "SELECT * FROM streamalerts.Services WHERE id = ?", (service_id,) - ) - assert row, "Newly updated service couldn't be retrieved" - return Service(**row) diff --git a/lnbits/extensions/streamalerts/migrations.py b/lnbits/extensions/streamalerts/migrations.py deleted file mode 100644 index 1b0cea37..00000000 --- a/lnbits/extensions/streamalerts/migrations.py +++ /dev/null @@ -1,35 +0,0 @@ -async def m001_initial(db): - - await db.execute( - f""" - CREATE TABLE IF NOT EXISTS streamalerts.Services ( - id {db.serial_primary_key}, - state TEXT NOT NULL, - twitchuser TEXT NOT NULL, - client_id TEXT NOT NULL, - client_secret TEXT NOT NULL, - wallet TEXT NOT NULL, - onchain TEXT, - servicename TEXT NOT NULL, - authenticated BOOLEAN NOT NULL, - token TEXT - ); - """ - ) - - await db.execute( - f""" - CREATE TABLE IF NOT EXISTS streamalerts.Donations ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - name TEXT NOT NULL, - message TEXT NOT NULL, - cur_code TEXT NOT NULL, - sats INT NOT NULL, - amount FLOAT NOT NULL, - service INTEGER NOT NULL, - posted BOOLEAN NOT NULL, - FOREIGN KEY(service) REFERENCES {db.references_schema}Services(id) - ); - """ - ) diff --git a/lnbits/extensions/streamalerts/models.py b/lnbits/extensions/streamalerts/models.py deleted file mode 100644 index 20923ebb..00000000 --- a/lnbits/extensions/streamalerts/models.py +++ /dev/null @@ -1,44 +0,0 @@ -from sqlite3 import Row -from typing import NamedTuple, Optional - - -class Donation(NamedTuple): - """A Donation simply contains all the necessary information about a - user's donation to a streamer - """ - - id: str # This ID always corresponds to a satspay charge ID - wallet: str - name: str # Name of the donor - message: str # Donation message - cur_code: str # Three letter currency code accepted by Streamlabs - sats: int - amount: float # The donation amount after fiat conversion - service: int # The ID of the corresponding Service - posted: bool # Whether the donation has already been posted to a Service - - @classmethod - def from_row(cls, row: Row) -> "Donation": - return cls(**dict(row)) - - -class Service(NamedTuple): - """A Service represents an integration with a third-party API - - Currently, Streamlabs is the only supported Service. - """ - - id: int - state: str # A random hash used during authentication - twitchuser: str # The Twitch streamer's username - client_id: str # Third party service Client ID - client_secret: str # Secret corresponding to the Client ID - wallet: str - onchain: str - servicename: str # Currently, this will just always be "Streamlabs" - authenticated: bool # Whether a token (see below) has been acquired yet - token: Optional[int] # The token with which to authenticate requests - - @classmethod - def from_row(cls, row: Row) -> "Service": - return cls(**dict(row)) diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html b/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html deleted file mode 100644 index 33b52f15..00000000 --- a/lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html +++ /dev/null @@ -1,18 +0,0 @@ - - -

- Stream Alerts: Integrate Bitcoin into your stream alerts! -

-

- Accept Bitcoin donations on Twitch, and integrate them into your alerts. - Present your viewers with a simple donation page, and add those donations - to Streamlabs to play alerts on your stream!
- For detailed setup instructions, check out - this guide!
- - Created by, Fitti -

-
-
diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/display.html b/lnbits/extensions/streamalerts/templates/streamalerts/display.html deleted file mode 100644 index a10e64d8..00000000 --- a/lnbits/extensions/streamalerts/templates/streamalerts/display.html +++ /dev/null @@ -1,97 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
Donate Bitcoin to {{ twitchuser }}
-
- - - - -
- Submit -
-
-
-
-
-
- -{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/index.html b/lnbits/extensions/streamalerts/templates/streamalerts/index.html deleted file mode 100644 index 46d1bb31..00000000 --- a/lnbits/extensions/streamalerts/templates/streamalerts/index.html +++ /dev/null @@ -1,502 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New Service - - - - - -
-
-
Services
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
- - - -
-
-
Donations
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
-
- - -
- {{SITE_TITLE}} Stream Alerts extension -
-
- - - {% include "streamalerts/_api_docs.html" %} - -
-
- - - - - - -
-
-
- -
-
- - - Watch-Only extension MUST be activated and have a wallet - - -
-
-
-
- -
- - - - -
- Update Service - - Create Service - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/streamalerts/views.py b/lnbits/extensions/streamalerts/views.py deleted file mode 100644 index 3e9e771d..00000000 --- a/lnbits/extensions/streamalerts/views.py +++ /dev/null @@ -1,28 +0,0 @@ -from quart import g, abort, render_template - -from lnbits.decorators import check_user_exists, validate_uuids -from http import HTTPStatus - -from . import streamalerts_ext -from .crud import get_service - - -@streamalerts_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - """Return the extension's settings page""" - return await render_template("streamalerts/index.html", user=g.user) - - -@streamalerts_ext.route("/") -async def donation(state): - """Return the donation form for the Service corresponding to state""" - service = await get_service(0, by_state=state) - if not service: - abort(HTTPStatus.NOT_FOUND, "Service does not exist.") - return await render_template( - "streamalerts/display.html", - twitchuser=service.twitchuser, - service=service.id - ) diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py deleted file mode 100644 index b914753c..00000000 --- a/lnbits/extensions/streamalerts/views_api.py +++ /dev/null @@ -1,273 +0,0 @@ -from quart import g, redirect, request, jsonify -from http import HTTPStatus - -from lnbits.decorators import api_validate_post_request, api_check_wallet_key -from lnbits.core.crud import get_user -from lnbits.utils.exchange_rates import btc_price - -from . import streamalerts_ext -from .crud import ( - get_charge_details, - get_service_redirect_uri, - create_donation, - post_donation, - get_donation, - get_donations, - delete_donation, - create_service, - get_service, - get_services, - authenticate_service, - update_donation, - update_service, - delete_service, -) -from ..satspay.crud import create_charge, get_charge - - -@streamalerts_ext.route("/api/v1/services", methods=["POST"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "twitchuser": {"type": "string", "required": True}, - "client_id": {"type": "string", "required": True}, - "client_secret": {"type": "string", "required": True}, - "wallet": {"type": "string", "required": True}, - "servicename": {"type": "string", "required": True}, - "onchain": {"type": "string"}, - } -) -async def api_create_service(): - """Create a service, which holds data about how/where to post donations""" - try: - service = await create_service(**g.data) - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - - return jsonify(service._asdict()), HTTPStatus.CREATED - - -@streamalerts_ext.route("/api/v1/getaccess/", methods=["GET"]) -async def api_get_access(service_id): - """Redirect to Streamlabs' Approve/Decline page for API access for Service - with service_id - """ - service = await get_service(service_id) - if service: - redirect_uri = await get_service_redirect_uri(request, service_id) - params = { - "response_type": "code", - "client_id": service.client_id, - "redirect_uri": redirect_uri, - "scope": "donations.create", - "state": service.state, - } - endpoint_url = "https://streamlabs.com/api/v1.0/authorize/?" - querystring = "&".join([f"{key}={value}" for key, value in params.items()]) - redirect_url = endpoint_url + querystring - return redirect(redirect_url) - else: - return (jsonify({"message": "Service does not exist!"}), HTTPStatus.BAD_REQUEST) - - -@streamalerts_ext.route("/api/v1/authenticate/", methods=["GET"]) -async def api_authenticate_service(service_id): - """Endpoint visited via redirect during third party API authentication - - If successful, an API access token will be added to the service, and - the user will be redirected to index.html. - """ - code = request.args.get("code") - state = request.args.get("state") - service = await get_service(service_id) - if service.state != state: - return (jsonify({"message": "State doesn't match!"}), HTTPStatus.BAD_Request) - redirect_uri = request.scheme + "://" + request.headers["Host"] - redirect_uri += f"/streamalerts/api/v1/authenticate/{service_id}" - url, success = await authenticate_service(service_id, code, redirect_uri) - if success: - return redirect(url) - else: - return ( - jsonify({"message": "Service already authenticated!"}), - HTTPStatus.BAD_REQUEST, - ) - - -@streamalerts_ext.route("/api/v1/donations", methods=["POST"]) -@api_validate_post_request( - schema={ - "name": {"type": "string"}, - "sats": {"type": "integer", "required": True}, - "service": {"type": "integer", "required": True}, - "message": {"type": "string"}, - } -) -async def api_create_donation(): - """Take data from donation form and return satspay charge""" - # Currency is hardcoded while frotnend is limited - cur_code = "USD" - sats = g.data["sats"] - message = g.data.get("message", "") - # Fiat amount is calculated here while frontend is limited - price = await btc_price(cur_code) - amount = sats * (10 ** (-8)) * price - webhook_base = request.scheme + "://" + request.headers["Host"] - service_id = g.data["service"] - service = await get_service(service_id) - charge_details = await get_charge_details(service.id) - name = g.data.get("name", "") - if not name: - name = "Anonymous" - description = f"{sats} sats donation from {name} to {service.twitchuser}" - charge = await create_charge( - amount=sats, - completelink=f"https://twitch.tv/{service.twitchuser}", - completelinktext="Back to Stream!", - webhook=webhook_base + "/streamalerts/api/v1/postdonation", - description=description, - **charge_details, - ) - await create_donation( - id=charge.id, - wallet=service.wallet, - message=message, - name=name, - cur_code=cur_code, - sats=g.data["sats"], - amount=amount, - service=g.data["service"], - ) - return (jsonify({"redirect_url": f"/satspay/{charge.id}"}), HTTPStatus.OK) - - -@streamalerts_ext.route("/api/v1/postdonation", methods=["POST"]) -@api_validate_post_request( - schema={ - "id": {"type": "string", "required": True}, - } -) -async def api_post_donation(): - """Post a paid donation to Stremalabs/StreamElements. - - This endpoint acts as a webhook for the SatsPayServer extension.""" - data = await request.get_json(force=True) - donation_id = data.get("id", "No ID") - charge = await get_charge(donation_id) - if charge and charge.paid: - return await post_donation(donation_id) - else: - return (jsonify({"message": "Not a paid charge!"}), HTTPStatus.BAD_REQUEST) - - -@streamalerts_ext.route("/api/v1/services", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_get_services(): - """Return list of all services assigned to wallet with given invoice key""" - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - services = [] - for wallet_id in wallet_ids: - new_services = await get_services(wallet_id) - services += new_services if new_services else [] - return ( - jsonify([service._asdict() for service in services] if services else []), - HTTPStatus.OK, - ) - - -@streamalerts_ext.route("/api/v1/donations", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_get_donations(): - """Return list of all donations assigned to wallet with given invoice - key - """ - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - donations = [] - for wallet_id in wallet_ids: - new_donations = await get_donations(wallet_id) - donations += new_donations if new_donations else [] - return ( - jsonify([donation._asdict() for donation in donations] if donations else []), - HTTPStatus.OK, - ) - - -@streamalerts_ext.route("/api/v1/donations/", methods=["PUT"]) -@api_check_wallet_key("invoice") -async def api_update_donation(donation_id=None): - """Update a donation with the data given in the request""" - if donation_id: - donation = await get_donation(donation_id) - - if not donation: - return ( - jsonify({"message": "Donation does not exist."}), - HTTPStatus.NOT_FOUND, - ) - - if donation.wallet != g.wallet.id: - return (jsonify({"message": "Not your donation."}), HTTPStatus.FORBIDDEN) - - donation = await update_donation(donation_id, **g.data) - else: - return ( - jsonify({"message": "No donation ID specified"}), - HTTPStatus.BAD_REQUEST, - ) - return jsonify(donation._asdict()), HTTPStatus.CREATED - - -@streamalerts_ext.route("/api/v1/services/", methods=["PUT"]) -@api_check_wallet_key("invoice") -async def api_update_service(service_id=None): - """Update a service with the data given in the request""" - if service_id: - service = await get_service(service_id) - - if not service: - return ( - jsonify({"message": "Service does not exist."}), - HTTPStatus.NOT_FOUND, - ) - - if service.wallet != g.wallet.id: - return (jsonify({"message": "Not your service."}), HTTPStatus.FORBIDDEN) - - service = await update_service(service_id, **g.data) - else: - return (jsonify({"message": "No service ID specified"}), HTTPStatus.BAD_REQUEST) - return jsonify(service._asdict()), HTTPStatus.CREATED - - -@streamalerts_ext.route("/api/v1/donations/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_delete_donation(donation_id): - """Delete the donation with the given donation_id""" - donation = await get_donation(donation_id) - if not donation: - return (jsonify({"message": "No donation with this ID!"}), HTTPStatus.NOT_FOUND) - if donation.wallet != g.wallet.id: - return ( - jsonify({"message": "Not authorized to delete this donation!"}), - HTTPStatus.FORBIDDEN, - ) - await delete_donation(donation_id) - - return "", HTTPStatus.NO_CONTENT - - -@streamalerts_ext.route("/api/v1/services/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_delete_service(service_id): - """Delete the service with the given service_id""" - service = await get_service(service_id) - if not service: - return (jsonify({"message": "No service with this ID!"}), HTTPStatus.NOT_FOUND) - if service.wallet != g.wallet.id: - return ( - jsonify({"message": "Not authorized to delete this service!"}), - HTTPStatus.FORBIDDEN, - ) - await delete_service(service_id) - - return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/subdomains/README.md b/lnbits/extensions/subdomains/README.md deleted file mode 100644 index 729f40f4..00000000 --- a/lnbits/extensions/subdomains/README.md +++ /dev/null @@ -1,54 +0,0 @@ -

Subdomains Extension

- -So the goal of the extension is to allow the owner of a domain to sell subdomains to anyone who is willing to pay some money for it. - -[![video tutorial livestream](http://img.youtube.com/vi/O1X0fy3uNpw/0.jpg)](https://youtu.be/O1X0fy3uNpw 'video tutorial subdomains') - -## Requirements - -- Free Cloudflare account -- Cloudflare as a DNS server provider -- Cloudflare TOKEN and Cloudflare zone-ID where the domain is parked - -## Usage - -1. Register at Cloudflare and setup your domain with them. (Just follow instructions they provide...) -2. Change DNS server at your domain registrar to point to Cloudflare's -3. Get Cloudflare zone-ID for your domain - -4. Get Cloudflare API TOKEN - - -5. Open the LNBits subdomains extension and register your domain -6. Click on the button in the table to open the public form that was generated for your domain - - - Extension also supports webhooks so you can get notified when someone buys a new subdomain\ - - -## API Endpoints - -- **Domains** - - GET /api/v1/domains - - POST /api/v1/domains - - PUT /api/v1/domains/ - - DELETE /api/v1/domains/ -- **Subdomains** - - GET /api/v1/subdomains - - POST /api/v1/subdomains/ - - GET /api/v1/subdomains/ - - DELETE /api/v1/subdomains/ - -### Cloudflare - -- Cloudflare offers programmatic subdomain registration... (create new A record) -- you can keep your existing domain's registrar, you just have to transfer dns records to the cloudflare (free service) -- more information: - - https://api.cloudflare.com/#getting-started-requests - - API endpoints needed for our project: - - https://api.cloudflare.com/#dns-records-for-a-zone-list-dns-records - - https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record - - https://api.cloudflare.com/#dns-records-for-a-zone-delete-dns-record - - https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record -- api can be used by providing authorization token OR authorization key - - check API Tokens and API Keys : https://api.cloudflare.com/#getting-started-requests -- Cloudflare API postman collection: https://support.cloudflare.com/hc/en-us/articles/115002323852-Using-Cloudflare-API-with-Postman-Collections diff --git a/lnbits/extensions/subdomains/__init__.py b/lnbits/extensions/subdomains/__init__.py deleted file mode 100644 index 5013230c..00000000 --- a/lnbits/extensions/subdomains/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_subdomains") - -subdomains_ext: Blueprint = Blueprint( - "subdomains", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa - -from .tasks import register_listeners -from lnbits.tasks import record_async - -subdomains_ext.record(record_async(register_listeners)) diff --git a/lnbits/extensions/subdomains/cloudflare.py b/lnbits/extensions/subdomains/cloudflare.py deleted file mode 100644 index 9a0fc4cf..00000000 --- a/lnbits/extensions/subdomains/cloudflare.py +++ /dev/null @@ -1,60 +0,0 @@ -from lnbits.extensions.subdomains.models import Domains -import httpx, json - - -async def cloudflare_create_subdomain( - domain: Domains, subdomain: str, record_type: str, ip: str -): - # Call to cloudflare sort of a dry-run - if success delete the domain and wait for payment - ### SEND REQUEST TO CLOUDFLARE - url = ( - "https://api.cloudflare.com/client/v4/zones/" - + domain.cf_zone_id - + "/dns_records" - ) - header = { - "Authorization": "Bearer " + domain.cf_token, - "Content-Type": "application/json", - } - aRecord = subdomain + "." + domain.domain - cf_response = "" - async with httpx.AsyncClient() as client: - try: - r = await client.post( - url, - headers=header, - json={ - "type": record_type, - "name": aRecord, - "content": ip, - "ttl": 0, - "proxed": False, - }, - timeout=40, - ) - cf_response = json.loads(r.text) - except AssertionError: - cf_response = "Error occured" - return cf_response - - -async def cloudflare_deletesubdomain(domain: Domains, domain_id: str): - url = ( - "https://api.cloudflare.com/client/v4/zones/" - + domain.cf_zone_id - + "/dns_records" - ) - header = { - "Authorization": "Bearer " + domain.cf_token, - "Content-Type": "application/json", - } - async with httpx.AsyncClient() as client: - try: - r = await client.delete( - url + "/" + domain_id, - headers=header, - timeout=40, - ) - cf_response = r.text - except AssertionError: - cf_response = "Error occured" diff --git a/lnbits/extensions/subdomains/config.json b/lnbits/extensions/subdomains/config.json deleted file mode 100644 index 6bf9480c..00000000 --- a/lnbits/extensions/subdomains/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Subdomains", - "short_description": "Sell subdomains of your domain", - "icon": "domain", - "contributors": ["grmkris"] -} diff --git a/lnbits/extensions/subdomains/crud.py b/lnbits/extensions/subdomains/crud.py deleted file mode 100644 index 08cb19eb..00000000 --- a/lnbits/extensions/subdomains/crud.py +++ /dev/null @@ -1,182 +0,0 @@ -from typing import List, Optional, Union - -from lnbits.helpers import urlsafe_short_hash - -from . import db -from .models import Domains, Subdomains - - -async def create_subdomain( - payment_hash: str, - wallet: str, - domain: str, - subdomain: str, - email: str, - ip: str, - sats: int, - duration: int, - record_type: str, -) -> Subdomains: - await db.execute( - """ - INSERT INTO subdomains.subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - payment_hash, - domain, - email, - subdomain, - ip, - wallet, - sats, - duration, - False, - record_type, - ), - ) - - new_subdomain = await get_subdomain(payment_hash) - assert new_subdomain, "Newly created subdomain couldn't be retrieved" - return new_subdomain - - -async def set_subdomain_paid(payment_hash: str) -> Subdomains: - row = await db.fetchone( - "SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?", - (payment_hash,), - ) - if row[8] == False: - await db.execute( - """ - UPDATE subdomains.subdomain - SET paid = true - WHERE id = ? - """, - (payment_hash,), - ) - - domaindata = await get_domain(row[1]) - assert domaindata, "Couldn't get domain from paid subdomain" - - amount = domaindata.amountmade + row[8] - await db.execute( - """ - UPDATE subdomains.domain - SET amountmade = ? - WHERE id = ? - """, - (amount, row[1]), - ) - - new_subdomain = await get_subdomain(payment_hash) - assert new_subdomain, "Newly paid subdomain couldn't be retrieved" - return new_subdomain - - -async def get_subdomain(subdomain_id: str) -> Optional[Subdomains]: - row = await db.fetchone( - "SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?", - (subdomain_id,), - ) - return Subdomains(**row) if row else None - - -async def get_subdomainBySubdomain(subdomain: str) -> Optional[Subdomains]: - row = await db.fetchone( - "SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.subdomain = ?", - (subdomain,), - ) - print(row) - return Subdomains(**row) if row else None - - -async def get_subdomains(wallet_ids: Union[str, List[str]]) -> List[Subdomains]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.wallet IN ({q})", - (*wallet_ids,), - ) - - return [Subdomains(**row) for row in rows] - - -async def delete_subdomain(subdomain_id: str) -> None: - await db.execute("DELETE FROM subdomains.subdomain WHERE id = ?", (subdomain_id,)) - - -# Domains - - -async def create_domain( - *, - wallet: str, - domain: str, - cf_token: str, - cf_zone_id: str, - webhook: Optional[str] = None, - description: str, - cost: int, - allowed_record_types: str, -) -> Domains: - domain_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO subdomains.domain (id, wallet, domain, webhook, cf_token, cf_zone_id, description, cost, amountmade, allowed_record_types) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - domain_id, - wallet, - domain, - webhook, - cf_token, - cf_zone_id, - description, - cost, - 0, - allowed_record_types, - ), - ) - - new_domain = await get_domain(domain_id) - assert new_domain, "Newly created domain couldn't be retrieved" - return new_domain - - -async def update_domain(domain_id: str, **kwargs) -> Domains: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE subdomains.domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id) - ) - row = await db.fetchone( - "SELECT * FROM subdomains.domain WHERE id = ?", (domain_id,) - ) - assert row, "Newly updated domain couldn't be retrieved" - return Domains(**row) - - -async def get_domain(domain_id: str) -> Optional[Domains]: - row = await db.fetchone( - "SELECT * FROM subdomains.domain WHERE id = ?", (domain_id,) - ) - return Domains(**row) if row else None - - -async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domains]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM subdomains.domain WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [Domains(**row) for row in rows] - - -async def delete_domain(domain_id: str) -> None: - await db.execute("DELETE FROM subdomains.domain WHERE id = ?", (domain_id,)) diff --git a/lnbits/extensions/subdomains/migrations.py b/lnbits/extensions/subdomains/migrations.py deleted file mode 100644 index 292d1f18..00000000 --- a/lnbits/extensions/subdomains/migrations.py +++ /dev/null @@ -1,41 +0,0 @@ -async def m001_initial(db): - - await db.execute( - """ - CREATE TABLE subdomains.domain ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - domain TEXT NOT NULL, - webhook TEXT, - cf_token TEXT NOT NULL, - cf_zone_id TEXT NOT NULL, - description TEXT NOT NULL, - cost INTEGER NOT NULL, - amountmade INTEGER NOT NULL, - allowed_record_types TEXT NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) - - await db.execute( - """ - CREATE TABLE subdomains.subdomain ( - id TEXT PRIMARY KEY, - domain TEXT NOT NULL, - email TEXT NOT NULL, - subdomain TEXT NOT NULL, - ip TEXT NOT NULL, - wallet TEXT NOT NULL, - sats INTEGER NOT NULL, - duration INTEGER NOT NULL, - paid BOOLEAN NOT NULL, - record_type TEXT NOT NULL, - time TIMESTAMP NOT NULL DEFAULT """ - + db.timestamp_now - + """ - ); - """ - ) diff --git a/lnbits/extensions/subdomains/models.py b/lnbits/extensions/subdomains/models.py deleted file mode 100644 index a519311e..00000000 --- a/lnbits/extensions/subdomains/models.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import NamedTuple - - -class Domains(NamedTuple): - id: str - wallet: str - domain: str - cf_token: str - cf_zone_id: str - webhook: str - description: str - cost: int - amountmade: int - time: int - allowed_record_types: str - - -class Subdomains(NamedTuple): - id: str - wallet: str - domain: str - domain_name: str - subdomain: str - email: str - ip: str - sats: int - duration: int - paid: bool - time: int - record_type: str diff --git a/lnbits/extensions/subdomains/tasks.py b/lnbits/extensions/subdomains/tasks.py deleted file mode 100644 index b15703fb..00000000 --- a/lnbits/extensions/subdomains/tasks.py +++ /dev/null @@ -1,61 +0,0 @@ -from http import HTTPStatus -from quart.json import jsonify -import trio -import httpx - -from .crud import get_domain, set_subdomain_paid -from lnbits.core.crud import get_user, get_wallet -from lnbits.core import db as core_db -from lnbits.core.models import Payment -from lnbits.tasks import register_invoice_listener -from .cloudflare import cloudflare_create_subdomain - - -async def register_listeners(): - invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) - register_invoice_listener(invoice_paid_chan_send) - await wait_for_paid_invoices(invoice_paid_chan_recv) - - -async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel): - async for payment in invoice_paid_chan: - await on_invoice_paid(payment) - - -async def on_invoice_paid(payment: Payment) -> None: - if "lnsubdomain" != payment.extra.get("tag"): - # not an lnurlp invoice - return - - await payment.set_pending(False) - subdomain = await set_subdomain_paid(payment_hash=payment.payment_hash) - domain = await get_domain(subdomain.domain) - - ### Create subdomain - cf_response = cloudflare_create_subdomain( - domain=domain, - subdomain=subdomain.subdomain, - record_type=subdomain.record_type, - ip=subdomain.ip, - ) - - ### Use webhook to notify about cloudflare registration - if domain.webhook: - async with httpx.AsyncClient() as client: - try: - r = await client.post( - domain.webhook, - json={ - "domain": subdomain.domain_name, - "subdomain": subdomain.subdomain, - "record_type": subdomain.record_type, - "email": subdomain.email, - "ip": subdomain.ip, - "cost:": str(subdomain.sats) + " sats", - "duration": str(subdomain.duration) + " days", - "cf_response": cf_response, - }, - timeout=40, - ) - except AssertionError: - webhook = None diff --git a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html deleted file mode 100644 index b839c641..00000000 --- a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html +++ /dev/null @@ -1,26 +0,0 @@ - - - -
- lnSubdomains: Get paid sats to sell your subdomains -
-

- Charge people for using your subdomain name...
- - More details -
- - Created by, Kris -

-
-
-
diff --git a/lnbits/extensions/subdomains/templates/subdomains/display.html b/lnbits/extensions/subdomains/templates/subdomains/display.html deleted file mode 100644 index e52ac73c..00000000 --- a/lnbits/extensions/subdomains/templates/subdomains/display.html +++ /dev/null @@ -1,221 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -

{{ domain_domain }}

-
-
{{ domain_desc }}
-
- - - - - - - - - -

- Cost per day: {{ domain_cost }} sats
- {% raw %} Total cost: {{amountSats}} sats {% endraw %} -

-
- Submit - Cancel -
-
-
-
-
- - - - - - -
- Copy invoice - Close -
-
-
-
- -{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/subdomains/templates/subdomains/index.html b/lnbits/extensions/subdomains/templates/subdomains/index.html deleted file mode 100644 index 26b5d7a6..00000000 --- a/lnbits/extensions/subdomains/templates/subdomains/index.html +++ /dev/null @@ -1,550 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} - -
-
- - - New Domain - - - - - -
-
-
Domains
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
- - - -
-
-
Subdomains
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
-
- - -
- {{SITE_TITLE}} Subdomain extension -
-
- - - {% include "subdomains/_api_docs.html" %} - -
-
- - - - - - - - - - - - - - - - -
- Update Form - Create Domain - Cancel -
-
-
-
-
- -{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/subdomains/util.py b/lnbits/extensions/subdomains/util.py deleted file mode 100644 index c7d66307..00000000 --- a/lnbits/extensions/subdomains/util.py +++ /dev/null @@ -1,36 +0,0 @@ -from lnbits.extensions.subdomains.models import Subdomains - -# Python3 program to validate -# domain name -# using regular expression -import re -import socket - -# Function to validate domain name. -def isValidDomain(str): - # Regex to check valid - # domain name. - regex = "^((?!-)[A-Za-z0-9-]{1,63}(?") -async def display(domain_id): - domain = await get_domain(domain_id) - if not domain: - abort(HTTPStatus.NOT_FOUND, "Domain does not exist.") - allowed_records = ( - domain.allowed_record_types.replace('"', "").replace(" ", "").split(",") - ) - print(allowed_records) - return await render_template( - "subdomains/display.html", - domain_id=domain.id, - domain_domain=domain.domain, - domain_desc=domain.description, - domain_cost=domain.cost, - domain_allowed_record_types=allowed_records, - ) diff --git a/lnbits/extensions/subdomains/views_api.py b/lnbits/extensions/subdomains/views_api.py deleted file mode 100644 index c11cd4be..00000000 --- a/lnbits/extensions/subdomains/views_api.py +++ /dev/null @@ -1,222 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus - -from lnbits.core.crud import get_user -from lnbits.core.services import create_invoice, check_invoice_status -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import subdomains_ext -from .crud import ( - create_subdomain, - get_subdomain, - get_subdomains, - delete_subdomain, - create_domain, - update_domain, - get_domain, - get_domains, - delete_domain, - get_subdomainBySubdomain, -) -from .cloudflare import cloudflare_create_subdomain, cloudflare_deletesubdomain - - -# domainS - - -@subdomains_ext.route("/api/v1/domains", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_domains(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([domain._asdict() for domain in await get_domains(wallet_ids)]), - HTTPStatus.OK, - ) - - -@subdomains_ext.route("/api/v1/domains", methods=["POST"]) -@subdomains_ext.route("/api/v1/domains/", methods=["PUT"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "wallet": {"type": "string", "empty": False, "required": True}, - "domain": {"type": "string", "empty": False, "required": True}, - "cf_token": {"type": "string", "empty": False, "required": True}, - "cf_zone_id": {"type": "string", "empty": False, "required": True}, - "webhook": {"type": "string", "empty": False, "required": False}, - "description": {"type": "string", "min": 0, "required": True}, - "cost": {"type": "integer", "min": 0, "required": True}, - "allowed_record_types": {"type": "string", "required": True}, - } -) -async def api_domain_create(domain_id=None): - if domain_id: - domain = await get_domain(domain_id) - - if not domain: - return jsonify({"message": "domain does not exist."}), HTTPStatus.NOT_FOUND - - if domain.wallet != g.wallet.id: - return jsonify({"message": "Not your domain."}), HTTPStatus.FORBIDDEN - - domain = await update_domain(domain_id, **g.data) - else: - domain = await create_domain(**g.data) - return jsonify(domain._asdict()), HTTPStatus.CREATED - - -@subdomains_ext.route("/api/v1/domains/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_domain_delete(domain_id): - domain = await get_domain(domain_id) - - if not domain: - return jsonify({"message": "domain does not exist."}), HTTPStatus.NOT_FOUND - - if domain.wallet != g.wallet.id: - return jsonify({"message": "Not your domain."}), HTTPStatus.FORBIDDEN - - await delete_domain(domain_id) - - return "", HTTPStatus.NO_CONTENT - - -#########subdomains########## - - -@subdomains_ext.route("/api/v1/subdomains", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_subdomains(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([domain._asdict() for domain in await get_subdomains(wallet_ids)]), - HTTPStatus.OK, - ) - - -@subdomains_ext.route("/api/v1/subdomains/", methods=["POST"]) -@api_validate_post_request( - schema={ - "domain": {"type": "string", "empty": False, "required": True}, - "subdomain": {"type": "string", "empty": False, "required": True}, - "email": {"type": "string", "empty": True, "required": True}, - "ip": {"type": "string", "empty": False, "required": True}, - "sats": {"type": "integer", "min": 0, "required": True}, - "duration": {"type": "integer", "empty": False, "required": True}, - "record_type": {"type": "string", "empty": False, "required": True}, - } -) -async def api_subdomain_make_subdomain(domain_id): - domain = await get_domain(domain_id) - - # If the request is coming for the non-existant domain - if not domain: - return jsonify({"message": "LNsubdomain does not exist."}), HTTPStatus.NOT_FOUND - - ## If record_type is not one of the allowed ones reject the request - if g.data["record_type"] not in domain.allowed_record_types: - return ( - jsonify({"message": g.data["record_type"] + "Not a valid record"}), - HTTPStatus.BAD_REQUEST, - ) - - ## If domain already exist in our database reject it - if await get_subdomainBySubdomain(g.data["subdomain"]) is not None: - return ( - jsonify( - { - "message": g.data["subdomain"] - + "." - + domain.domain - + " domain already taken" - } - ), - HTTPStatus.BAD_REQUEST, - ) - - ## Dry run cloudflare... (create and if create is sucessful delete it) - cf_response = await cloudflare_create_subdomain( - domain=domain, - subdomain=g.data["subdomain"], - record_type=g.data["record_type"], - ip=g.data["ip"], - ) - if cf_response["success"] == True: - cloudflare_deletesubdomain(domain=domain, domain_id=cf_response["result"]["id"]) - else: - return ( - jsonify( - { - "message": "Problem with cloudflare: " - + cf_response["errors"][0]["message"] - } - ), - HTTPStatus.BAD_REQUEST, - ) - - ## ALL OK - create an invoice and return it to the user - sats = g.data["sats"] - - try: - payment_hash, payment_request = await create_invoice( - wallet_id=domain.wallet, - amount=sats, - memo=f"subdomain {g.data['subdomain']}.{domain.domain} for {sats} sats for {g.data['duration']} days", - extra={"tag": "lnsubdomain"}, - ) - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - - subdomain = await create_subdomain( - payment_hash=payment_hash, wallet=domain.wallet, **g.data - ) - - if not subdomain: - return ( - jsonify({"message": "LNsubdomain could not be fetched."}), - HTTPStatus.NOT_FOUND, - ) - - return ( - jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), - HTTPStatus.OK, - ) - - -@subdomains_ext.route("/api/v1/subdomains/", methods=["GET"]) -async def api_subdomain_send_subdomain(payment_hash): - subdomain = await get_subdomain(payment_hash) - try: - status = await check_invoice_status(subdomain.wallet, payment_hash) - is_paid = not status.pending - except Exception: - return jsonify({"paid": False}), HTTPStatus.OK - - if is_paid: - return jsonify({"paid": True}), HTTPStatus.OK - - return jsonify({"paid": False}), HTTPStatus.OK - - -@subdomains_ext.route("/api/v1/subdomains/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_subdomain_delete(subdomain_id): - subdomain = await get_subdomain(subdomain_id) - - if not subdomain: - return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND - - if subdomain.wallet != g.wallet.id: - return jsonify({"message": "Not your subdomain."}), HTTPStatus.FORBIDDEN - - await delete_subdomain(subdomain_id) - - return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/tpos/README.md b/lnbits/extensions/tpos/README.md deleted file mode 100644 index 04e049e3..00000000 --- a/lnbits/extensions/tpos/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# TPoS - -## A Shareable PoS (Point of Sale) that doesn't need to be installed and can run in the browser! - -An easy, fast and secure way to accept Bitcoin, over Lightning Network, at your business. The PoS is isolated from the wallet, so it's safe for any employee to use. You can create as many TPOS's as you need, for example one for each employee, or one for each branch of your business. - -### Usage - -1. Enable extension -2. Create a TPOS\ - ![create](https://imgur.com/8jNj8Zq.jpg) -3. Open TPOS on the browser\ - ![open](https://imgur.com/LZuoWzb.jpg) -4. Present invoice QR to costumer\ - ![pay](https://imgur.com/tOwxn77.jpg) diff --git a/lnbits/extensions/tpos/__init__.py b/lnbits/extensions/tpos/__init__.py deleted file mode 100644 index daa3022e..00000000 --- a/lnbits/extensions/tpos/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_tpos") - -tpos_ext: Blueprint = Blueprint( - "tpos", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/tpos/config.json b/lnbits/extensions/tpos/config.json deleted file mode 100644 index c5789afb..00000000 --- a/lnbits/extensions/tpos/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "TPoS", - "short_description": "A shareable PoS terminal!", - "icon": "dialpad", - "contributors": ["talvasconcelos", "arcbtc"] -} diff --git a/lnbits/extensions/tpos/crud.py b/lnbits/extensions/tpos/crud.py deleted file mode 100644 index 99dab662..00000000 --- a/lnbits/extensions/tpos/crud.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import List, Optional, Union - -from lnbits.helpers import urlsafe_short_hash - -from . import db -from .models import TPoS - - -async def create_tpos(*, wallet_id: str, name: str, currency: str) -> TPoS: - tpos_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO tpos.tposs (id, wallet, name, currency) - VALUES (?, ?, ?, ?) - """, - (tpos_id, wallet_id, name, currency), - ) - - tpos = await get_tpos(tpos_id) - assert tpos, "Newly created tpos couldn't be retrieved" - return tpos - - -async def get_tpos(tpos_id: str) -> Optional[TPoS]: - row = await db.fetchone("SELECT * FROM tpos.tposs WHERE id = ?", (tpos_id,)) - return TPoS.from_row(row) if row else None - - -async def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM tpos.tposs WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [TPoS.from_row(row) for row in rows] - - -async def delete_tpos(tpos_id: str) -> None: - await db.execute("DELETE FROM tpos.tposs WHERE id = ?", (tpos_id,)) diff --git a/lnbits/extensions/tpos/migrations.py b/lnbits/extensions/tpos/migrations.py deleted file mode 100644 index 7a7fff0d..00000000 --- a/lnbits/extensions/tpos/migrations.py +++ /dev/null @@ -1,14 +0,0 @@ -async def m001_initial(db): - """ - Initial tposs table. - """ - await db.execute( - """ - CREATE TABLE tpos.tposs ( - id TEXT PRIMARY KEY, - wallet TEXT NOT NULL, - name TEXT NOT NULL, - currency TEXT NOT NULL - ); - """ - ) diff --git a/lnbits/extensions/tpos/models.py b/lnbits/extensions/tpos/models.py deleted file mode 100644 index e1061567..00000000 --- a/lnbits/extensions/tpos/models.py +++ /dev/null @@ -1,13 +0,0 @@ -from sqlite3 import Row -from typing import NamedTuple - - -class TPoS(NamedTuple): - id: str - wallet: str - name: str - currency: str - - @classmethod - def from_row(cls, row: Row) -> "TPoS": - return cls(**dict(row)) diff --git a/lnbits/extensions/tpos/templates/tpos/_api_docs.html b/lnbits/extensions/tpos/templates/tpos/_api_docs.html deleted file mode 100644 index 6ceab728..00000000 --- a/lnbits/extensions/tpos/templates/tpos/_api_docs.html +++ /dev/null @@ -1,78 +0,0 @@ - - - - - GET /tpos/api/v1/tposs -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [<tpos_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/tposs -H "X-Api-Key: - <invoice_key>" - -
-
-
- - - - POST /tpos/api/v1/tposs -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
- {"name": <string>, "currency": <string*ie USD*>} -
- Returns 201 CREATED (application/json) -
- {"currency": <string>, "id": <string>, "name": - <string>, "wallet": <string>} -
Curl example
- curl -X POST {{ request.url_root }}api/v1/tposs -d '{"name": - <string>, "currency": <string>}' -H "Content-type: - application/json" -H "X-Api-Key: <admin_key>" - -
-
-
- - - - - DELETE - /tpos/api/v1/tposs/<tpos_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.url_root }}api/v1/tposs/<tpos_id> -H - "X-Api-Key: <admin_key>" - -
-
-
-
diff --git a/lnbits/extensions/tpos/templates/tpos/_tpos.html b/lnbits/extensions/tpos/templates/tpos/_tpos.html deleted file mode 100644 index 54ddcd0f..00000000 --- a/lnbits/extensions/tpos/templates/tpos/_tpos.html +++ /dev/null @@ -1,18 +0,0 @@ - - - -

- Thiago's Point of Sale is a secure, mobile-ready, instant and shareable - point of sale terminal (PoS) for merchants. The PoS is linked to your - LNbits wallet but completely air-gapped so users can ONLY create - invoices. To share the TPoS hit the hash on the terminal. -

- Created by - Tiago Vasconcelos. -
-
-
diff --git a/lnbits/extensions/tpos/templates/tpos/index.html b/lnbits/extensions/tpos/templates/tpos/index.html deleted file mode 100644 index f3b55b37..00000000 --- a/lnbits/extensions/tpos/templates/tpos/index.html +++ /dev/null @@ -1,423 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New TPoS - - - - - -
-
-
TPoS
-
-
- Export to CSV -
-
- - {% raw %} - - - - {% endraw %} - -
-
-
- -
- - -
{{SITE_TITLE}} TPoS extension
-
- - - - {% include "tpos/_api_docs.html" %} - - {% include "tpos/_tpos.html" %} - - -
-
- - - - - - - -
- Create TPoS - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html deleted file mode 100644 index 1727e6e9..00000000 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ /dev/null @@ -1,264 +0,0 @@ -{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock -%} {% block footer %}{% endblock %} {% block page_container %} - - - -
-
-

{% raw %}{{ famount }}{% endraw %}

-
- {% raw %}{{ fsat }}{% endraw %} sat -
-
-
-
- -
-
-
- 1 - 2 - 3 - C - 4 - 5 - 6 - 7 - 8 - 9 - OK - DEL - 0 - # -
-
-
-
- - - - - -
-

{% raw %}{{ famount }}{% endraw %}

-
- {% raw %}{{ fsat }}{% endraw %} sat -
-
-
- Close -
-
-
- - - - - -
-

- {{ tpos.name }}
{{ request.url }} -

-
-
- Copy URL - Close -
-
-
-
-
-{% endblock %} {% block styles %} - -{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/tpos/views.py b/lnbits/extensions/tpos/views.py deleted file mode 100644 index ce842295..00000000 --- a/lnbits/extensions/tpos/views.py +++ /dev/null @@ -1,23 +0,0 @@ -from quart import g, abort, render_template -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import tpos_ext -from .crud import get_tpos - - -@tpos_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("tpos/index.html", user=g.user) - - -@tpos_ext.route("/") -async def tpos(tpos_id): - tpos = await get_tpos(tpos_id) - if not tpos: - abort(HTTPStatus.NOT_FOUND, "TPoS does not exist.") - - return await render_template("tpos/tpos.html", tpos=tpos) diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py deleted file mode 100644 index 1f0802c7..00000000 --- a/lnbits/extensions/tpos/views_api.py +++ /dev/null @@ -1,101 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus - -from lnbits.core.crud import get_user, get_wallet -from lnbits.core.services import create_invoice, check_invoice_status -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import tpos_ext -from .crud import create_tpos, get_tpos, get_tposs, delete_tpos - - -@tpos_ext.route("/api/v1/tposs", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_tposs(): - wallet_ids = [g.wallet.id] - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - - return ( - jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]), - HTTPStatus.OK, - ) - - -@tpos_ext.route("/api/v1/tposs", methods=["POST"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "name": {"type": "string", "empty": False, "required": True}, - "currency": {"type": "string", "empty": False, "required": True}, - } -) -async def api_tpos_create(): - tpos = await create_tpos(wallet_id=g.wallet.id, **g.data) - return jsonify(tpos._asdict()), HTTPStatus.CREATED - - -@tpos_ext.route("/api/v1/tposs/", methods=["DELETE"]) -@api_check_wallet_key("admin") -async def api_tpos_delete(tpos_id): - tpos = await get_tpos(tpos_id) - - if not tpos: - return jsonify({"message": "TPoS does not exist."}), HTTPStatus.NOT_FOUND - - if tpos.wallet != g.wallet.id: - return jsonify({"message": "Not your TPoS."}), HTTPStatus.FORBIDDEN - - await delete_tpos(tpos_id) - - return "", HTTPStatus.NO_CONTENT - - -@tpos_ext.route("/api/v1/tposs//invoices/", methods=["POST"]) -@api_validate_post_request( - schema={"amount": {"type": "integer", "min": 1, "required": True}} -) -async def api_tpos_create_invoice(tpos_id): - tpos = await get_tpos(tpos_id) - - if not tpos: - return jsonify({"message": "TPoS does not exist."}), HTTPStatus.NOT_FOUND - - try: - payment_hash, payment_request = await create_invoice( - wallet_id=tpos.wallet, - amount=g.data["amount"], - memo=f"{tpos.name}", - extra={"tag": "tpos"}, - ) - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR - - return ( - jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), - HTTPStatus.CREATED, - ) - - -@tpos_ext.route("/api/v1/tposs//invoices/", methods=["GET"]) -async def api_tpos_check_invoice(tpos_id, payment_hash): - tpos = await get_tpos(tpos_id) - - if not tpos: - return jsonify({"message": "TPoS does not exist."}), HTTPStatus.NOT_FOUND - - try: - status = await check_invoice_status(tpos.wallet, payment_hash) - is_paid = not status.pending - except Exception as exc: - print(exc) - return jsonify({"paid": False}), HTTPStatus.OK - - if is_paid: - wallet = await get_wallet(tpos.wallet) - payment = await wallet.get_payment(payment_hash) - await payment.set_pending(False) - - return jsonify({"paid": True}), HTTPStatus.OK - - return jsonify({"paid": False}), HTTPStatus.OK diff --git a/lnbits/extensions/usermanager/README.md b/lnbits/extensions/usermanager/README.md deleted file mode 100644 index b6f30627..00000000 --- a/lnbits/extensions/usermanager/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# User Manager - -## Make and manage users/wallets - -To help developers use LNbits to manage their users, the User Manager extension allows the creation and management of users and wallets. - -For example, a games developer may be developing a game that needs each user to have their own wallet, LNbits can be included in the developers stack as the user and wallet manager. Or someone wanting to manage their family's wallets (wife, children, parents, etc...) or you want to host a community Lightning Network node and want to manage wallets for the users. - -## Usage - -1. Click the button "NEW USER" to create a new user\ - ![new user](https://i.imgur.com/4yZyfJE.png) -2. Fill the user information\ - - username - - the generated wallet name, user can create other wallets later on - - email - - set a password - ![user information](https://i.imgur.com/40du7W5.png) -3. After creating your user, it will appear in the **Users** section, and a user's wallet in the **Wallets** section. -4. Next you can share the wallet with the corresponding user\ - ![user wallet](https://i.imgur.com/gAyajbx.png) -5. If you need to create more wallets for some user, click "NEW WALLET" at the top\ - ![multiple wallets](https://i.imgur.com/wovVnim.png) - - select the existing user you wish to add the wallet - - set a wallet name\ - ![new wallet](https://i.imgur.com/sGwG8dC.png) diff --git a/lnbits/extensions/usermanager/__init__.py b/lnbits/extensions/usermanager/__init__.py deleted file mode 100644 index 53154812..00000000 --- a/lnbits/extensions/usermanager/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_usermanager") - -usermanager_ext: Blueprint = Blueprint( - "usermanager", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/usermanager/config.json b/lnbits/extensions/usermanager/config.json deleted file mode 100644 index 7391ec29..00000000 --- a/lnbits/extensions/usermanager/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "User Manager", - "short_description": "Generate users and wallets", - "icon": "person_add", - "contributors": ["benarc"] -} diff --git a/lnbits/extensions/usermanager/crud.py b/lnbits/extensions/usermanager/crud.py deleted file mode 100644 index a7854ad8..00000000 --- a/lnbits/extensions/usermanager/crud.py +++ /dev/null @@ -1,122 +0,0 @@ -from typing import Optional, List - -from lnbits.core.models import Payment -from lnbits.core.crud import ( - create_account, - get_user, - get_payments, - create_wallet, - delete_wallet, -) - -from . import db -from .models import Users, Wallets - - -### Users - - -async def create_usermanager_user( - user_name: str, - wallet_name: str, - admin_id: str, - email: Optional[str] = None, - password: Optional[str] = None, -) -> Users: - account = await create_account() - user = await get_user(account.id) - assert user, "Newly created user couldn't be retrieved" - - wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name) - - await db.execute( - """ - INSERT INTO usermanager.users (id, name, admin, email, password) - VALUES (?, ?, ?, ?, ?) - """, - (user.id, user_name, admin_id, email, password), - ) - - await db.execute( - """ - INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey) - VALUES (?, ?, ?, ?, ?, ?) - """, - (wallet.id, admin_id, wallet_name, user.id, wallet.adminkey, wallet.inkey), - ) - - user_created = await get_usermanager_user(user.id) - assert user_created, "Newly created user couldn't be retrieved" - return user_created - - -async def get_usermanager_user(user_id: str) -> Optional[Users]: - row = await db.fetchone("SELECT * FROM usermanager.users WHERE id = ?", (user_id,)) - return Users(**row) if row else None - - -async def get_usermanager_users(user_id: str) -> List[Users]: - rows = await db.fetchall( - "SELECT * FROM usermanager.users WHERE admin = ?", (user_id,) - ) - return [Users(**row) for row in rows] - - -async def delete_usermanager_user(user_id: str) -> None: - wallets = await get_usermanager_wallets(user_id) - for wallet in wallets: - await delete_wallet(user_id=user_id, wallet_id=wallet.id) - - await db.execute("DELETE FROM usermanager.users WHERE id = ?", (user_id,)) - await db.execute("""DELETE FROM usermanager.wallets WHERE "user" = ?""", (user_id,)) - - -### Wallets - - -async def create_usermanager_wallet( - user_id: str, wallet_name: str, admin_id: str -) -> Wallets: - wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name) - await db.execute( - """ - INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey) - VALUES (?, ?, ?, ?, ?, ?) - """, - (wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey), - ) - wallet_created = await get_usermanager_wallet(wallet.id) - assert wallet_created, "Newly created wallet couldn't be retrieved" - return wallet_created - - -async def get_usermanager_wallet(wallet_id: str) -> Optional[Wallets]: - row = await db.fetchone( - "SELECT * FROM usermanager.wallets WHERE id = ?", (wallet_id,) - ) - return Wallets(**row) if row else None - - -async def get_usermanager_wallets(admin_id: str) -> Optional[Wallets]: - rows = await db.fetchall( - "SELECT * FROM usermanager.wallets WHERE admin = ?", (admin_id,) - ) - return [Wallets(**row) for row in rows] - - -async def get_usermanager_users_wallets(user_id: str) -> Optional[Wallets]: - rows = await db.fetchall( - """SELECT * FROM usermanager.wallets WHERE "user" = ?""", (user_id,) - ) - return [Wallets(**row) for row in rows] - - -async def get_usermanager_wallet_transactions(wallet_id: str) -> Optional[Payment]: - return await get_payments( - wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True - ) - - -async def delete_usermanager_wallet(wallet_id: str, user_id: str) -> None: - await delete_wallet(user_id=user_id, wallet_id=wallet_id) - await db.execute("DELETE FROM usermanager.wallets WHERE id = ?", (wallet_id,)) diff --git a/lnbits/extensions/usermanager/migrations.py b/lnbits/extensions/usermanager/migrations.py deleted file mode 100644 index 62a21575..00000000 --- a/lnbits/extensions/usermanager/migrations.py +++ /dev/null @@ -1,31 +0,0 @@ -async def m001_initial(db): - """ - Initial users table. - """ - await db.execute( - """ - CREATE TABLE usermanager.users ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - admin TEXT NOT NULL, - email TEXT, - password TEXT - ); - """ - ) - - """ - Initial wallets table. - """ - await db.execute( - """ - CREATE TABLE usermanager.wallets ( - id TEXT PRIMARY KEY, - admin TEXT NOT NULL, - name TEXT NOT NULL, - "user" TEXT NOT NULL, - adminkey TEXT NOT NULL, - inkey TEXT NOT NULL - ); - """ - ) diff --git a/lnbits/extensions/usermanager/models.py b/lnbits/extensions/usermanager/models.py deleted file mode 100644 index 97eaaea8..00000000 --- a/lnbits/extensions/usermanager/models.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import NamedTuple -from sqlite3 import Row - - -class Users(NamedTuple): - id: str - name: str - admin: str - email: str - password: str - - -class Wallets(NamedTuple): - id: str - admin: str - name: str - user: str - adminkey: str - inkey: str - - @classmethod - def from_row(cls, row: Row) -> "Wallets": - return cls(**dict(row)) diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html deleted file mode 100644 index 74640bb8..00000000 --- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html +++ /dev/null @@ -1,259 +0,0 @@ - - - -
- User Manager: Make and manager users/wallets -
-

- To help developers use LNbits to manage their users, the User Manager - extension allows the creation and management of users and wallets. -
For example, a games developer may be developing a game that needs - each user to have their own wallet, LNbits can be included in the - develpoers stack as the user and wallet manager.
- - Created by, Ben Arc -

-
-
-
- - - - - GET - /usermanager/api/v1/users -
Body (application/json)
-
- Returns 201 CREATED (application/json) -
- JSON list of users -
Curl example
- curl -X GET {{ request.url_root }}usermanager/api/v1/users -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - GET - /usermanager/api/v1/users/<user_id> -
Body (application/json)
-
- Returns 201 CREATED (application/json) -
- JSON list of users -
Curl example
- curl -X GET {{ request.url_root }}usermanager/api/v1/users/<user_id> -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - GET - /usermanager/api/v1/wallets/<user_id> -
Headers
- {"X-Api-Key": <string>} -
Body (application/json)
-
- Returns 201 CREATED (application/json) -
- JSON wallet data -
Curl example
- curl -X GET {{ request.url_root }}usermanager/api/v1/wallets/<user_id> -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - GET - /usermanager/api/v1/wallets<wallet_id> -
Headers
- {"X-Api-Key": <string>} -
Body (application/json)
-
- Returns 201 CREATED (application/json) -
- JSON a wallets transactions -
Curl example
- curl -X GET {{ request.url_root }}usermanager/api/v1/wallets<wallet_id> -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - POST - /usermanager/api/v1/users -
Headers
- {"X-Api-Key": <string>, "Content-type": - "application/json"} -
- Body (application/json) - "admin_id" is a YOUR user ID -
- {"admin_id": <string>, "user_name": <string>, - "wallet_name": <string>,"email": <Optional string> - ,"password": <Optional string>} -
- Returns 201 CREATED (application/json) -
- {"id": <string>, "name": <string>, "admin": - <string>, "email": <string>, "password": - <string>} -
Curl example
- curl -X POST {{ request.url_root }}usermanager/api/v1/users -d '{"admin_id": "{{ - g.user.id }}", "wallet_name": <string>, "user_name": - <string>, "email": <Optional string>, "password": < - Optional string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H - "Content-type: application/json" - -
-
-
- - - - POST - /usermanager/api/v1/wallets -
Headers
- {"X-Api-Key": <string>, "Content-type": - "application/json"} -
- Body (application/json) - "admin_id" is a YOUR user ID -
- {"user_id": <string>, "wallet_name": <string>, - "admin_id": <string>} -
- Returns 201 CREATED (application/json) -
- {"id": <string>, "admin": <string>, "name": - <string>, "user": <string>, "adminkey": <string>, - "inkey": <string>} -
Curl example
- curl -X POST {{ request.url_root }}usermanager/api/v1/wallets -d '{"user_id": - <string>, "wallet_name": <string>, "admin_id": "{{ - g.user.id }}"}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H - "Content-type: application/json" - -
-
-
- - - - DELETE - /usermanager/api/v1/users/<user_id> -
Headers
- {"X-Api-Key": <string>} -
Curl example
- curl -X DELETE {{ request.url_root }}usermanager/api/v1/users/<user_id> -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - DELETE - /usermanager/api/v1/wallets/<wallet_id> -
Headers
- {"X-Api-Key": <string>} -
Curl example
- curl -X DELETE {{ request.url_root }}usermanager/api/v1/wallets/<wallet_id> - -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - POST - /usermanager/api/v1/extensions -
Headers
- {"X-Api-Key": <string>} -
Curl example
- curl -X POST {{ request.url_root }}usermanager/api/v1/extensions -d '{"userid": - <string>, "extension": <string>, "active": - <integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H - "Content-type: application/json" - -
-
-
-
diff --git a/lnbits/extensions/usermanager/templates/usermanager/index.html b/lnbits/extensions/usermanager/templates/usermanager/index.html deleted file mode 100644 index 446ee51d..00000000 --- a/lnbits/extensions/usermanager/templates/usermanager/index.html +++ /dev/null @@ -1,473 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New User - New Wallet - - - - - - -
-
-
Users
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
- - - -
-
-
Wallets
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
- -
- - -
- {{SITE_TITLE}} User Manager Extension -
-
- - - {% include "usermanager/_api_docs.html" %} - -
-
- - - - - - - - - - Create User - Cancel - - - - - - - - - - - Create Wallet - Cancel - - - -
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/usermanager/views.py b/lnbits/extensions/usermanager/views.py deleted file mode 100644 index df6949c6..00000000 --- a/lnbits/extensions/usermanager/views.py +++ /dev/null @@ -1,12 +0,0 @@ -from quart import g, render_template - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import usermanager_ext - - -@usermanager_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("usermanager/index.html", user=g.user) diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py deleted file mode 100644 index d3bba6ad..00000000 --- a/lnbits/extensions/usermanager/views_api.py +++ /dev/null @@ -1,156 +0,0 @@ -from quart import g, jsonify -from http import HTTPStatus - -from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import usermanager_ext -from .crud import ( - create_usermanager_user, - get_usermanager_user, - get_usermanager_users, - get_usermanager_wallet_transactions, - delete_usermanager_user, - create_usermanager_wallet, - get_usermanager_wallet, - get_usermanager_wallets, - get_usermanager_users_wallets, - delete_usermanager_wallet, -) -from lnbits.core import update_user_extension - - -### Users - - -@usermanager_ext.route("/api/v1/users", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_usermanager_users(): - user_id = g.wallet.user - return ( - jsonify([user._asdict() for user in await get_usermanager_users(user_id)]), - HTTPStatus.OK, - ) - - -@usermanager_ext.route("/api/v1/users/", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_usermanager_user(user_id): - user = await get_usermanager_user(user_id) - return ( - jsonify(user._asdict()), - HTTPStatus.OK, - ) - - -@usermanager_ext.route("/api/v1/users", methods=["POST"]) -@api_check_wallet_key(key_type="invoice") -@api_validate_post_request( - schema={ - "user_name": {"type": "string", "empty": False, "required": True}, - "wallet_name": {"type": "string", "empty": False, "required": True}, - "admin_id": {"type": "string", "empty": False, "required": True}, - "email": {"type": "string", "required": False}, - "password": {"type": "string", "required": False}, - } -) -async def api_usermanager_users_create(): - user = await create_usermanager_user(**g.data) - full = user._asdict() - full["wallets"] = [wallet._asdict() for wallet in await get_usermanager_users_wallets(user.id)] - return jsonify(full), HTTPStatus.CREATED - - -@usermanager_ext.route("/api/v1/users/", methods=["DELETE"]) -@api_check_wallet_key(key_type="invoice") -async def api_usermanager_users_delete(user_id): - user = await get_usermanager_user(user_id) - if not user: - return jsonify({"message": "User does not exist."}), HTTPStatus.NOT_FOUND - await delete_usermanager_user(user_id) - return "", HTTPStatus.NO_CONTENT - - -###Activate Extension - - -@usermanager_ext.route("/api/v1/extensions", methods=["POST"]) -@api_check_wallet_key(key_type="invoice") -@api_validate_post_request( - schema={ - "extension": {"type": "string", "empty": False, "required": True}, - "userid": {"type": "string", "empty": False, "required": True}, - "active": {"type": "boolean", "required": True}, - } -) -async def api_usermanager_activate_extension(): - user = await get_user(g.data["userid"]) - if not user: - return jsonify({"message": "no such user"}), HTTPStatus.NOT_FOUND - update_user_extension( - user_id=g.data["userid"], extension=g.data["extension"], active=g.data["active"] - ) - return jsonify({"extension": "updated"}), HTTPStatus.CREATED - - -###Wallets - - -@usermanager_ext.route("/api/v1/wallets", methods=["POST"]) -@api_check_wallet_key(key_type="invoice") -@api_validate_post_request( - schema={ - "user_id": {"type": "string", "empty": False, "required": True}, - "wallet_name": {"type": "string", "empty": False, "required": True}, - "admin_id": {"type": "string", "empty": False, "required": True}, - } -) -async def api_usermanager_wallets_create(): - user = await create_usermanager_wallet( - g.data["user_id"], g.data["wallet_name"], g.data["admin_id"] - ) - return jsonify(user._asdict()), HTTPStatus.CREATED - - -@usermanager_ext.route("/api/v1/wallets", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_usermanager_wallets(): - admin_id = g.wallet.user - return ( - jsonify( - [wallet._asdict() for wallet in await get_usermanager_wallets(admin_id)] - ), - HTTPStatus.OK, - ) - - -@usermanager_ext.route("/api/v1/wallets", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_usermanager_wallet_transactions(wallet_id): - return jsonify(await get_usermanager_wallet_transactions(wallet_id)), HTTPStatus.OK - - -@usermanager_ext.route("/api/v1/wallets/", methods=["GET"]) -@api_check_wallet_key(key_type="invoice") -async def api_usermanager_users_wallets(user_id): - wallet = await get_usermanager_users_wallets(user_id) - return ( - jsonify( - [ - wallet._asdict() - for wallet in await get_usermanager_users_wallets(user_id) - ] - ), - HTTPStatus.OK, - ) - - -@usermanager_ext.route("/api/v1/wallets/", methods=["DELETE"]) -@api_check_wallet_key(key_type="invoice") -async def api_usermanager_wallets_delete(wallet_id): - wallet = await get_usermanager_wallet(wallet_id) - if not wallet: - return jsonify({"message": "Wallet does not exist."}), HTTPStatus.NOT_FOUND - - await delete_usermanager_wallet(wallet_id, wallet.user) - return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/watchonly/README.md b/lnbits/extensions/watchonly/README.md deleted file mode 100644 index d93f7162..00000000 --- a/lnbits/extensions/watchonly/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Watch Only wallet - -## Monitor an onchain wallet and generate addresses for onchain payments - -Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API. - -1. Start by clicking "NEW WALLET"\ - ![new wallet](https://i.imgur.com/vgbAB7c.png) -2. Fill the requested fields: - - give the wallet a name - - paste an Extended Public Key (xpub, ypub, zpub) - - click "CREATE WATCH-ONLY WALLET"\ - ![fill wallet form](https://i.imgur.com/UVoG7LD.png) -3. You can then access your onchain addresses\ - ![get address](https://i.imgur.com/zkxTQ6l.png) -4. You can then generate bitcoin onchain adresses from LNbits\ - ![onchain address](https://i.imgur.com/4KVSSJn.png) - -You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension diff --git a/lnbits/extensions/watchonly/__init__.py b/lnbits/extensions/watchonly/__init__.py deleted file mode 100644 index b8df3197..00000000 --- a/lnbits/extensions/watchonly/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_watchonly") - - -watchonly_ext: Blueprint = Blueprint( - "watchonly", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa diff --git a/lnbits/extensions/watchonly/config.json b/lnbits/extensions/watchonly/config.json deleted file mode 100644 index 48c19ef0..00000000 --- a/lnbits/extensions/watchonly/config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "Watch Only", - "short_description": "Onchain watch only wallets", - "icon": "visibility", - "contributors": [ - "arcbtc" - ] -} diff --git a/lnbits/extensions/watchonly/crud.py b/lnbits/extensions/watchonly/crud.py deleted file mode 100644 index bd301eb4..00000000 --- a/lnbits/extensions/watchonly/crud.py +++ /dev/null @@ -1,212 +0,0 @@ -from typing import List, Optional - -from . import db -from .models import Wallets, Addresses, Mempool - -from lnbits.helpers import urlsafe_short_hash - -from embit.descriptor import Descriptor, Key # type: ignore -from embit.descriptor.arguments import AllowedDerivation # type: ignore -from embit.networks import NETWORKS # type: ignore - - -##########################WALLETS#################### - - -def detect_network(k): - version = k.key.version - for network_name in NETWORKS: - net = NETWORKS[network_name] - # not found in this network - if version in [net["xpub"], net["ypub"], net["zpub"], net["Zpub"], net["Ypub"]]: - return net - - -def parse_key(masterpub: str): - """Parses masterpub or descriptor and returns a tuple: (Descriptor, network) - To create addresses use descriptor.derive(num).address(network=network) - """ - network = None - # probably a single key - if "(" not in masterpub: - k = Key.from_string(masterpub) - if not k.is_extended: - raise ValueError("The key is not a master public key") - if k.is_private: - raise ValueError("Private keys are not allowed") - # check depth - if k.key.depth != 3: - raise ValueError( - "Non-standard depth. Only bip44, bip49 and bip84 are supported with bare xpubs. For custom derivation paths use descriptors." - ) - # if allowed derivation is not provided use default /{0,1}/* - if k.allowed_derivation is None: - k.allowed_derivation = AllowedDerivation.default() - # get version bytes - version = k.key.version - for network_name in NETWORKS: - net = NETWORKS[network_name] - # not found in this network - if version in [net["xpub"], net["ypub"], net["zpub"]]: - network = net - if version == net["xpub"]: - desc = Descriptor.from_string("pkh(%s)" % str(k)) - elif version == net["ypub"]: - desc = Descriptor.from_string("sh(wpkh(%s))" % str(k)) - elif version == net["zpub"]: - desc = Descriptor.from_string("wpkh(%s)" % str(k)) - break - # we didn't find correct version - if network is None: - raise ValueError("Unknown master public key version") - else: - desc = Descriptor.from_string(masterpub) - if not desc.is_wildcard: - raise ValueError("Descriptor should have wildcards") - for k in desc.keys: - if k.is_extended: - net = detect_network(k) - if net is None: - raise ValueError(f"Unknown version: {k}") - if network is not None and network != net: - raise ValueError("Keys from different networks") - network = net - return desc, network - - -async def create_watch_wallet(*, user: str, masterpub: str, title: str) -> Wallets: - # check the masterpub is fine, it will raise an exception if not - parse_key(masterpub) - wallet_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO watchonly.wallets ( - id, - "user", - masterpub, - title, - address_no, - balance - ) - VALUES (?, ?, ?, ?, ?, ?) - """, - # address_no is -1 so fresh address on empty wallet can get address with index 0 - (wallet_id, user, masterpub, title, -1, 0), - ) - - return await get_watch_wallet(wallet_id) - - -async def get_watch_wallet(wallet_id: str) -> Optional[Wallets]: - row = await db.fetchone( - "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) - ) - return Wallets.from_row(row) if row else None - - -async def get_watch_wallets(user: str) -> List[Wallets]: - rows = await db.fetchall( - """SELECT * FROM watchonly.wallets WHERE "user" = ?""", (user,) - ) - return [Wallets(**row) for row in rows] - - -async def update_watch_wallet(wallet_id: str, **kwargs) -> Optional[Wallets]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - - await db.execute( - f"UPDATE watchonly.wallets SET {q} WHERE id = ?", (*kwargs.values(), wallet_id) - ) - row = await db.fetchone( - "SELECT * FROM watchonly.wallets WHERE id = ?", (wallet_id,) - ) - return Wallets.from_row(row) if row else None - - -async def delete_watch_wallet(wallet_id: str) -> None: - await db.execute("DELETE FROM watchonly.wallets WHERE id = ?", (wallet_id,)) - - ########################ADDRESSES####################### - - -async def get_derive_address(wallet_id: str, num: int): - wallet = await get_watch_wallet(wallet_id) - key = wallet[2] - desc, network = parse_key(key) - return desc.derive(num).address(network=network) - - -async def get_fresh_address(wallet_id: str) -> Optional[Addresses]: - wallet = await get_watch_wallet(wallet_id) - if not wallet: - return None - - address = await get_derive_address(wallet_id, wallet[4] + 1) - - await update_watch_wallet(wallet_id=wallet_id, address_no=wallet[4] + 1) - masterpub_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO watchonly.addresses ( - id, - address, - wallet, - amount - ) - VALUES (?, ?, ?, ?) - """, - (masterpub_id, address, wallet_id, 0), - ) - - return await get_address(address) - - -async def get_address(address: str) -> Optional[Addresses]: - row = await db.fetchone( - "SELECT * FROM watchonly.addresses WHERE address = ?", (address,) - ) - return Addresses.from_row(row) if row else None - - -async def get_addresses(wallet_id: str) -> List[Addresses]: - rows = await db.fetchall( - "SELECT * FROM watchonly.addresses WHERE wallet = ?", (wallet_id,) - ) - return [Addresses(**row) for row in rows] - - -######################MEMPOOL####################### - - -async def create_mempool(user: str) -> Optional[Mempool]: - await db.execute( - """ - INSERT INTO watchonly.mempool ("user",endpoint) - VALUES (?, ?) - """, - (user, "https://mempool.space"), - ) - row = await db.fetchone( - """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) - ) - return Mempool.from_row(row) if row else None - - -async def update_mempool(user: str, **kwargs) -> Optional[Mempool]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - - await db.execute( - f"""UPDATE watchonly.mempool SET {q} WHERE "user" = ?""", - (*kwargs.values(), user), - ) - row = await db.fetchone( - """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) - ) - return Mempool.from_row(row) if row else None - - -async def get_mempool(user: str) -> Mempool: - row = await db.fetchone( - """SELECT * FROM watchonly.mempool WHERE "user" = ?""", (user,) - ) - return Mempool.from_row(row) if row else None diff --git a/lnbits/extensions/watchonly/migrations.py b/lnbits/extensions/watchonly/migrations.py deleted file mode 100644 index 05c229b5..00000000 --- a/lnbits/extensions/watchonly/migrations.py +++ /dev/null @@ -1,36 +0,0 @@ -async def m001_initial(db): - """ - Initial wallet table. - """ - await db.execute( - """ - CREATE TABLE watchonly.wallets ( - id TEXT NOT NULL PRIMARY KEY, - "user" TEXT, - masterpub TEXT NOT NULL, - title TEXT NOT NULL, - address_no INTEGER NOT NULL DEFAULT 0, - balance INTEGER NOT NULL - ); - """ - ) - - await db.execute( - """ - CREATE TABLE watchonly.addresses ( - id TEXT NOT NULL PRIMARY KEY, - address TEXT NOT NULL, - wallet TEXT NOT NULL, - amount INTEGER NOT NULL - ); - """ - ) - - await db.execute( - """ - CREATE TABLE watchonly.mempool ( - "user" TEXT NOT NULL, - endpoint TEXT NOT NULL - ); - """ - ) diff --git a/lnbits/extensions/watchonly/models.py b/lnbits/extensions/watchonly/models.py deleted file mode 100644 index b9faa601..00000000 --- a/lnbits/extensions/watchonly/models.py +++ /dev/null @@ -1,35 +0,0 @@ -from sqlite3 import Row -from typing import NamedTuple - - -class Wallets(NamedTuple): - id: str - user: str - masterpub: str - title: str - address_no: int - balance: int - - @classmethod - def from_row(cls, row: Row) -> "Wallets": - return cls(**dict(row)) - - -class Mempool(NamedTuple): - user: str - endpoint: str - - @classmethod - def from_row(cls, row: Row) -> "Mempool": - return cls(**dict(row)) - - -class Addresses(NamedTuple): - id: str - address: str - wallet: str - amount: int - - @classmethod - def from_row(cls, row: Row) -> "Addresses": - return cls(**dict(row)) diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html deleted file mode 100644 index 97fdb8a9..00000000 --- a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html +++ /dev/null @@ -1,244 +0,0 @@ - - -

- Watch Only extension uses mempool.space
- For use with "account Extended Public Key" - https://iancoleman.io/bip39/ - -
Created by, - Ben Arc (using, - Embit
) -

-
- - - - - - GET /watchonly/api/v1/wallet -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<wallets_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/wallet -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - GET - /watchonly/api/v1/wallet/<wallet_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 201 CREATED (application/json) -
- [<wallet_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/wallet/<wallet_id> - -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - POST /watchonly/api/v1/wallet -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 201 CREATED (application/json) -
- [<wallet_object>, ...] -
Curl example
- curl -X POST {{ request.url_root }}api/v1/wallet -d '{"title": - <string>, "masterpub": <string>}' -H "Content-type: - application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" - -
-
-
- - - - DELETE - /watchonly/api/v1/wallet/<wallet_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.url_root - }}api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - -
-
-
- - - - - GET - /watchonly/api/v1/addresses/<wallet_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<address_object>, ...] -
Curl example
- curl -X GET {{ request.url_root - }}api/v1/addresses/<wallet_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - - GET - /watchonly/api/v1/address/<wallet_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<address_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/address/<wallet_id> - -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - - GET /watchonly/api/v1/mempool -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 200 OK (application/json) -
- [<mempool_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/mempool -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - -
-
-
- - - - - POST - /watchonly/api/v1/mempool -
Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json) -
-
- Returns 201 CREATED (application/json) -
- [<mempool_object>, ...] -
Curl example
- curl -X PUT {{ request.url_root }}api/v1/mempool -d '{"endpoint": - <string>}' -H "Content-type: application/json" -H "X-Api-Key: - {{ g.user.wallets[0].adminkey }}" - -
-
-
-
-
diff --git a/lnbits/extensions/watchonly/templates/watchonly/index.html b/lnbits/extensions/watchonly/templates/watchonly/index.html deleted file mode 100644 index 521c99fa..00000000 --- a/lnbits/extensions/watchonly/templates/watchonly/index.html +++ /dev/null @@ -1,476 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - {% raw %} - New wallet - - -
- Point to another Mempool - {{ this.mempool.endpoint }} - - -
- set - cancel -
-
-
-
-
-
- - - -
-
-
Wallets
-
-
- - - -
-
- - - - -
-
-
- - {% endraw %} - -
- - -
- {{SITE_TITLE}} Watch Only Extension -
-
- - - {% include "watchonly/_api_docs.html" %} - -
-
- - - - - - - - -
- Create Watch-only Wallet - Cancel -
-
-
-
- - - - {% raw %} -
Addresses
-
-

- Current: - {{ currentaddress }} - -

- - - -

- - - - {{ data.address }} - - - - -

- -
- Get fresh address - Close -
-
-
- {% endraw %} -
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - - - -{% endblock %} diff --git a/lnbits/extensions/watchonly/views.py b/lnbits/extensions/watchonly/views.py deleted file mode 100644 index e8246968..00000000 --- a/lnbits/extensions/watchonly/views.py +++ /dev/null @@ -1,22 +0,0 @@ -from quart import g, abort, render_template -from http import HTTPStatus - -from lnbits.decorators import check_user_exists, validate_uuids - -from . import watchonly_ext - - -@watchonly_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("watchonly/index.html", user=g.user) - - -@watchonly_ext.route("/") -async def display(charge_id): - link = get_payment(charge_id) or abort( - HTTPStatus.NOT_FOUND, "Charge link does not exist." - ) - - return await render_template("watchonly/display.html", link=link) diff --git a/lnbits/extensions/watchonly/views_api.py b/lnbits/extensions/watchonly/views_api.py deleted file mode 100644 index 01ae2527..00000000 --- a/lnbits/extensions/watchonly/views_api.py +++ /dev/null @@ -1,138 +0,0 @@ -import hashlib -from quart import g, jsonify, url_for, request -from http import HTTPStatus -import httpx -import json - -from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from lnbits.extensions.watchonly import watchonly_ext -from .crud import ( - create_watch_wallet, - get_watch_wallet, - get_watch_wallets, - update_watch_wallet, - delete_watch_wallet, - get_fresh_address, - get_addresses, - create_mempool, - update_mempool, - get_mempool, -) - -###################WALLETS############################# - - -@watchonly_ext.route("/api/v1/wallet", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_wallets_retrieve(): - - try: - return ( - jsonify( - [wallet._asdict() for wallet in await get_watch_wallets(g.wallet.user)] - ), - HTTPStatus.OK, - ) - except: - return "" - - -@watchonly_ext.route("/api/v1/wallet/", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_wallet_retrieve(wallet_id): - wallet = await get_watch_wallet(wallet_id) - - if not wallet: - return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND - - return jsonify(wallet._asdict()), HTTPStatus.OK - - -@watchonly_ext.route("/api/v1/wallet", methods=["POST"]) -@api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "masterpub": {"type": "string", "empty": False, "required": True}, - "title": {"type": "string", "empty": False, "required": True}, - } -) -async def api_wallet_create_or_update(wallet_id=None): - try: - wallet = await create_watch_wallet( - user=g.wallet.user, masterpub=g.data["masterpub"], title=g.data["title"] - ) - except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.BAD_REQUEST - mempool = await get_mempool(g.wallet.user) - if not mempool: - create_mempool(user=g.wallet.user) - return jsonify(wallet._asdict()), HTTPStatus.CREATED - - -@watchonly_ext.route("/api/v1/wallet/", methods=["DELETE"]) -@api_check_wallet_key("admin") -async def api_wallet_delete(wallet_id): - wallet = await get_watch_wallet(wallet_id) - - if not wallet: - return jsonify({"message": "Wallet link does not exist."}), HTTPStatus.NOT_FOUND - - await delete_watch_wallet(wallet_id) - - return jsonify({"deleted": "true"}), HTTPStatus.NO_CONTENT - - -#############################ADDRESSES########################## - - -@watchonly_ext.route("/api/v1/address/", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_fresh_address(wallet_id): - await get_fresh_address(wallet_id) - - addresses = await get_addresses(wallet_id) - - return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK - - -@watchonly_ext.route("/api/v1/addresses/", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_get_addresses(wallet_id): - wallet = await get_watch_wallet(wallet_id) - - if not wallet: - return jsonify({"message": "wallet does not exist"}), HTTPStatus.NOT_FOUND - - addresses = await get_addresses(wallet_id) - - if not addresses: - await get_fresh_address(wallet_id) - addresses = await get_addresses(wallet_id) - - return jsonify([address._asdict() for address in addresses]), HTTPStatus.OK - - -#############################MEMPOOL########################## - - -@watchonly_ext.route("/api/v1/mempool", methods=["PUT"]) -@api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "endpoint": {"type": "string", "empty": False, "required": True}, - } -) -async def api_update_mempool(): - mempool = await update_mempool(user=g.wallet.user, **g.data) - return jsonify(mempool._asdict()), HTTPStatus.OK - - -@watchonly_ext.route("/api/v1/mempool", methods=["GET"]) -@api_check_wallet_key("admin") -async def api_get_mempool(): - mempool = await get_mempool(g.wallet.user) - if not mempool: - mempool = await create_mempool(user=g.wallet.user) - return jsonify(mempool._asdict()), HTTPStatus.OK diff --git a/lnbits/extensions/withdraw/README.md b/lnbits/extensions/withdraw/README.md deleted file mode 100644 index 0e5939fd..00000000 --- a/lnbits/extensions/withdraw/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# LNURLw - -## Create a static QR code people can use to withdraw funds from a Lightning Network wallet - -LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL withdraw is the permission for someone to pull a certain amount of funds from a lightning wallet. - -The most common use case for an LNURL withdraw is a faucet, although it is a very powerful technology, with much further reaching implications. For example, an LNURL withdraw could be minted to pay for a subscription service. Or you can have a LNURLw as an offline Lightning wallet (a pre paid "card"), you use to pay for something without having to even reach your smartphone. - -LNURL withdraw is a **very powerful tool** and should not have his use limited to just faucet applications. With LNURL withdraw, you have the ability to give someone the right to spend a range, once or multiple times. **This functionality has not existed in money before**. - -[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) - -## Usage - -#### Quick Vouchers - -LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc... - -1. Create Quick Vouchers\ - ![quick vouchers](https://i.imgur.com/IUfwdQz.jpg) - - select wallet - - set the amount each voucher will allow someone to withdraw - - set the amount of vouchers you want to create - _have in mind you need to have a balance on the wallet that supports the amount \* number of vouchers_ -2. You can now print, share, display your LNURLw links or QR codes\ - ![lnurlw created](https://i.imgur.com/X00twiX.jpg) - - on details you can print the vouchers\ - ![printable vouchers](https://i.imgur.com/2xLHbob.jpg) - - every printed LNURLw QR code is unique, it can only be used once - -#### Advanced - -1. Create the Advanced LNURLw\ - ![create advanced lnurlw](https://i.imgur.com/OR0f885.jpg) - - set the wallet - - set a title for the LNURLw (it will show up in users wallet) - - define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value - - set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times - - LNBits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans - - you can set the time in _seconds, minutes or hours_ - - the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned -2. Print, share or display your LNURLw link or it's QR code\ - ![lnurlw created](https://i.imgur.com/X00twiX.jpg) - -**LNBits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNBits wallet! - -![](https://i.imgur.com/2zZ7mi8.jpg) diff --git a/lnbits/extensions/withdraw/__init__.py b/lnbits/extensions/withdraw/__init__.py deleted file mode 100644 index 69e45e4d..00000000 --- a/lnbits/extensions/withdraw/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from quart import Blueprint -from lnbits.db import Database - -db = Database("ext_withdraw") - - -withdraw_ext: Blueprint = Blueprint( - "withdraw", __name__, static_folder="static", template_folder="templates" -) - - -from .views_api import * # noqa -from .views import * # noqa -from .lnurl import * # noqa diff --git a/lnbits/extensions/withdraw/config.json b/lnbits/extensions/withdraw/config.json deleted file mode 100644 index de82e7f1..00000000 --- a/lnbits/extensions/withdraw/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "LNURLw", - "short_description": "Make LNURL withdraw links", - "icon": "crop_free", - "contributors": ["arcbtc", "eillarra"] -} diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py deleted file mode 100644 index 14178a0c..00000000 --- a/lnbits/extensions/withdraw/crud.py +++ /dev/null @@ -1,159 +0,0 @@ -from datetime import datetime -from typing import List, Optional, Union -from lnbits.helpers import urlsafe_short_hash - -from . import db -from .models import WithdrawLink, HashCheck - - -async def create_withdraw_link( - *, - wallet_id: str, - title: str, - min_withdrawable: int, - max_withdrawable: int, - uses: int, - wait_time: int, - is_unique: bool, - usescsv: str, -) -> WithdrawLink: - link_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO withdraw.withdraw_link ( - id, - wallet, - title, - min_withdrawable, - max_withdrawable, - uses, - wait_time, - is_unique, - unique_hash, - k1, - open_time, - usescsv - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - link_id, - wallet_id, - title, - min_withdrawable, - max_withdrawable, - uses, - wait_time, - int(is_unique), - urlsafe_short_hash(), - urlsafe_short_hash(), - int(datetime.now().timestamp()) + wait_time, - usescsv, - ), - ) - link = await get_withdraw_link(link_id, 0) - assert link, "Newly created link couldn't be retrieved" - return link - - -async def get_withdraw_link(link_id: str, num=0) -> Optional[WithdrawLink]: - row = await db.fetchone( - "SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,) - ) - if not row: - return None - - link = [] - for item in row: - link.append(item) - link.append(num) - return WithdrawLink._make(link) - - -async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> Optional[WithdrawLink]: - row = await db.fetchone( - "SELECT * FROM withdraw.withdraw_link WHERE unique_hash = ?", (unique_hash,) - ) - if not row: - return None - - link = [] - for item in row: - link.append(item) - link.append(num) - return WithdrawLink._make(link) - - -async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[WithdrawLink]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM withdraw.withdraw_link WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [WithdrawLink.from_row(row) for row in rows] - - -async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]: - if "is_unique" in kwargs: - kwargs["is_unique"] = int(kwargs["is_unique"]) - - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) - await db.execute( - f"UPDATE withdraw.withdraw_link SET {q} WHERE id = ?", - (*kwargs.values(), link_id), - ) - row = await db.fetchone( - "SELECT * FROM withdraw.withdraw_link WHERE id = ?", (link_id,) - ) - return WithdrawLink.from_row(row) if row else None - - -async def delete_withdraw_link(link_id: str) -> None: - await db.execute("DELETE FROM withdraw.withdraw_link WHERE id = ?", (link_id,)) - - -def chunks(lst, n): - for i in range(0, len(lst), n): - yield lst[i : i + n] - - -async def create_hash_check( - the_hash: str, - lnurl_id: str, -) -> HashCheck: - await db.execute( - """ - INSERT INTO withdraw.hash_check ( - id, - lnurl_id - ) - VALUES (?, ?) - """, - ( - the_hash, - lnurl_id, - ), - ) - hashCheck = await get_hash_check(the_hash, lnurl_id) - return hashCheck - - -async def get_hash_check(the_hash: str, lnurl_id: str) -> Optional[HashCheck]: - rowid = await db.fetchone( - "SELECT * FROM withdraw.hash_check WHERE id = ?", (the_hash,) - ) - rowlnurl = await db.fetchone( - "SELECT * FROM withdraw.hash_check WHERE lnurl_id = ?", (lnurl_id,) - ) - if not rowlnurl: - await create_hash_check(the_hash, lnurl_id) - return {"lnurl": True, "hash": False} - else: - if not rowid: - await create_hash_check(the_hash, lnurl_id) - return {"lnurl": True, "hash": False} - else: - return {"lnurl": True, "hash": True} diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py deleted file mode 100644 index 1322b6e2..00000000 --- a/lnbits/extensions/withdraw/lnurl.py +++ /dev/null @@ -1,139 +0,0 @@ -import shortuuid # type: ignore -from http import HTTPStatus -from datetime import datetime -from quart import jsonify, request - -from lnbits.core.services import pay_invoice - -from . import withdraw_ext -from .crud import get_withdraw_link_by_hash, update_withdraw_link - - -# FOR LNURLs WHICH ARE NOT UNIQUE - - -@withdraw_ext.route("/api/v1/lnurl/", methods=["GET"]) -async def api_lnurl_response(unique_hash): - link = await get_withdraw_link_by_hash(unique_hash) - - if not link: - return ( - jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), - HTTPStatus.OK, - ) - - if link.is_spent: - return ( - jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), - HTTPStatus.OK, - ) - - return jsonify(link.lnurl_response.dict()), HTTPStatus.OK - - -# FOR LNURLs WHICH ARE UNIQUE - - -@withdraw_ext.route("/api/v1/lnurl//", methods=["GET"]) -async def api_lnurl_multi_response(unique_hash, id_unique_hash): - link = await get_withdraw_link_by_hash(unique_hash) - - if not link: - return ( - jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), - HTTPStatus.OK, - ) - - if link.is_spent: - return ( - jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), - HTTPStatus.OK, - ) - - useslist = link.usescsv.split(",") - found = False - for x in useslist: - tohash = link.id + link.unique_hash + str(x) - if id_unique_hash == shortuuid.uuid(name=tohash): - found = True - if not found: - return ( - jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), - HTTPStatus.OK, - ) - - return jsonify(link.lnurl_response.dict()), HTTPStatus.OK - - -# CALLBACK - - -@withdraw_ext.route("/api/v1/lnurl/cb/", methods=["GET"]) -async def api_lnurl_callback(unique_hash): - link = await get_withdraw_link_by_hash(unique_hash) - k1 = request.args.get("k1", type=str) - payment_request = request.args.get("pr", type=str) - now = int(datetime.now().timestamp()) - - if not link: - return ( - jsonify({"status": "ERROR", "reason": "LNURL-withdraw not found."}), - HTTPStatus.OK, - ) - - if link.is_spent: - return ( - jsonify({"status": "ERROR", "reason": "Withdraw is spent."}), - HTTPStatus.OK, - ) - - if link.k1 != k1: - return jsonify({"status": "ERROR", "reason": "Bad request."}), HTTPStatus.OK - - if now < link.open_time: - return ( - jsonify( - {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."} - ), - HTTPStatus.OK, - ) - - try: - usescsv = "" - for x in range(1, link.uses - link.used): - usecv = link.usescsv.split(",") - usescsv += "," + str(usecv[x]) - usecsvback = usescsv - usescsv = usescsv[1:] - - changesback = { - "open_time": link.wait_time, - "used": link.used, - "usescsv": usecsvback, - } - - changes = { - "open_time": link.wait_time + now, - "used": link.used + 1, - "usescsv": usescsv, - } - - await update_withdraw_link(link.id, **changes) - - await pay_invoice( - wallet_id=link.wallet, - payment_request=payment_request, - max_sat=link.max_withdrawable, - extra={"tag": "withdraw"}, - ) - except ValueError as e: - await update_withdraw_link(link.id, **changesback) - return jsonify({"status": "ERROR", "reason": str(e)}) - except PermissionError: - await update_withdraw_link(link.id, **changesback) - return jsonify({"status": "ERROR", "reason": "Withdraw link is empty."}) - except Exception as e: - await update_withdraw_link(link.id, **changesback) - return jsonify({"status": "ERROR", "reason": str(e)}) - - return jsonify({"status": "OK"}), HTTPStatus.OK diff --git a/lnbits/extensions/withdraw/migrations.py b/lnbits/extensions/withdraw/migrations.py deleted file mode 100644 index 1a13aa6d..00000000 --- a/lnbits/extensions/withdraw/migrations.py +++ /dev/null @@ -1,110 +0,0 @@ -async def m001_initial(db): - """ - Creates an improved withdraw table and migrates the existing data. - """ - await db.execute( - """ - CREATE TABLE withdraw.withdraw_links ( - id TEXT PRIMARY KEY, - wallet TEXT, - title TEXT, - min_withdrawable INTEGER DEFAULT 1, - max_withdrawable INTEGER DEFAULT 1, - uses INTEGER DEFAULT 1, - wait_time INTEGER, - is_unique INTEGER DEFAULT 0, - unique_hash TEXT UNIQUE, - k1 TEXT, - open_time INTEGER, - used INTEGER DEFAULT 0, - usescsv TEXT - ); - """ - ) - - -async def m002_change_withdraw_table(db): - """ - Creates an improved withdraw table and migrates the existing data. - """ - await db.execute( - """ - CREATE TABLE withdraw.withdraw_link ( - id TEXT PRIMARY KEY, - wallet TEXT, - title TEXT, - min_withdrawable INTEGER DEFAULT 1, - max_withdrawable INTEGER DEFAULT 1, - uses INTEGER DEFAULT 1, - wait_time INTEGER, - is_unique INTEGER DEFAULT 0, - unique_hash TEXT UNIQUE, - k1 TEXT, - open_time INTEGER, - used INTEGER DEFAULT 0, - usescsv TEXT - ); - """ - ) - - for row in [ - list(row) for row in await db.fetchall("SELECT * FROM withdraw.withdraw_links") - ]: - usescsv = "" - - for i in range(row[5]): - if row[7]: - usescsv += "," + str(i + 1) - else: - usescsv += "," + str(1) - usescsv = usescsv[1:] - await db.execute( - """ - INSERT INTO withdraw.withdraw_link ( - id, - wallet, - title, - min_withdrawable, - max_withdrawable, - uses, - wait_time, - is_unique, - unique_hash, - k1, - open_time, - used, - usescsv - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - row[0], - row[1], - row[2], - row[3], - row[4], - row[5], - row[6], - row[7], - row[8], - row[9], - row[10], - row[11], - usescsv, - ), - ) - await db.execute("DROP TABLE withdraw.withdraw_links") - - -async def m003_make_hash_check(db): - """ - Creates a hash check table. - """ - await db.execute( - """ - CREATE TABLE withdraw.hash_check ( - id TEXT PRIMARY KEY, - lnurl_id TEXT - ); - """ - ) diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py deleted file mode 100644 index b7a98970..00000000 --- a/lnbits/extensions/withdraw/models.py +++ /dev/null @@ -1,76 +0,0 @@ -from quart import url_for -from lnurl import Lnurl, LnurlWithdrawResponse, encode as lnurl_encode # type: ignore -from sqlite3 import Row -from typing import NamedTuple -import shortuuid # type: ignore - - -class WithdrawLink(NamedTuple): - id: str - wallet: str - title: str - min_withdrawable: int - max_withdrawable: int - uses: int - wait_time: int - is_unique: bool - unique_hash: str - k1: str - open_time: int - used: int - usescsv: str - number: int - - @classmethod - def from_row(cls, row: Row) -> "WithdrawLink": - data = dict(row) - data["is_unique"] = bool(data["is_unique"]) - data["number"] = 0 - return cls(**data) - - @property - def is_spent(self) -> bool: - return self.used >= self.uses - - @property - def lnurl(self) -> Lnurl: - if self.is_unique: - usescssv = self.usescsv.split(",") - tohash = self.id + self.unique_hash + usescssv[self.number] - multihash = shortuuid.uuid(name=tohash) - url = url_for( - "withdraw.api_lnurl_multi_response", - unique_hash=self.unique_hash, - id_unique_hash=multihash, - _external=True, - ) - else: - url = url_for( - "withdraw.api_lnurl_response", - unique_hash=self.unique_hash, - _external=True, - ) - - return lnurl_encode(url) - - @property - def lnurl_response(self) -> LnurlWithdrawResponse: - url = url_for( - "withdraw.api_lnurl_callback", unique_hash=self.unique_hash, _external=True - ) - return LnurlWithdrawResponse( - callback=url, - k1=self.k1, - min_withdrawable=self.min_withdrawable * 1000, - max_withdrawable=self.max_withdrawable * 1000, - default_description=self.title, - ) - - -class HashCheck(NamedTuple): - id: str - lnurl_id: str - - @classmethod - def from_row(cls, row: Row) -> "Hash": - return cls(**dict(row)) diff --git a/lnbits/extensions/withdraw/static/js/index.js b/lnbits/extensions/withdraw/static/js/index.js deleted file mode 100644 index 2237d52b..00000000 --- a/lnbits/extensions/withdraw/static/js/index.js +++ /dev/null @@ -1,246 +0,0 @@ -/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ - -Vue.component(VueQrcode.name, VueQrcode) - -var locationPath = [ - window.location.protocol, - '//', - window.location.host, - window.location.pathname -].join('') - -var mapWithdrawLink = function (obj) { - obj._data = _.clone(obj) - obj.date = Quasar.utils.date.formatDate( - new Date(obj.time * 1000), - 'YYYY-MM-DD HH:mm' - ) - obj.min_fsat = new Intl.NumberFormat(LOCALE).format(obj.min_withdrawable) - obj.max_fsat = new Intl.NumberFormat(LOCALE).format(obj.max_withdrawable) - obj.uses_left = obj.uses - obj.used - obj.print_url = [locationPath, 'print/', obj.id].join('') - obj.withdraw_url = [locationPath, obj.id].join('') - return obj -} - -new Vue({ - el: '#vue', - mixins: [windowMixin], - data: function () { - return { - checker: null, - withdrawLinks: [], - withdrawLinksTable: { - columns: [ - {name: 'id', align: 'left', label: 'ID', field: 'id'}, - {name: 'title', align: 'left', label: 'Title', field: 'title'}, - { - name: 'wait_time', - align: 'right', - label: 'Wait', - field: 'wait_time' - }, - { - name: 'uses_left', - align: 'right', - label: 'Uses left', - field: 'uses_left' - }, - {name: 'min', align: 'right', label: 'Min (sat)', field: 'min_fsat'}, - {name: 'max', align: 'right', label: 'Max (sat)', field: 'max_fsat'} - ], - pagination: { - rowsPerPage: 10 - } - }, - formDialog: { - show: false, - secondMultiplier: 'seconds', - secondMultiplierOptions: ['seconds', 'minutes', 'hours'], - data: { - is_unique: false - } - }, - simpleformDialog: { - show: false, - data: { - is_unique: true, - title: 'Vouchers', - min_withdrawable: 0, - wait_time: 1 - } - }, - qrCodeDialog: { - show: false, - data: null - } - } - }, - computed: { - sortedWithdrawLinks: function () { - return this.withdrawLinks.sort(function (a, b) { - return b.uses_left - a.uses_left - }) - } - }, - methods: { - getWithdrawLinks: function () { - var self = this - - LNbits.api - .request( - 'GET', - '/withdraw/api/v1/links?all_wallets', - this.g.user.wallets[0].inkey - ) - .then(function (response) { - self.withdrawLinks = response.data.map(function (obj) { - return mapWithdrawLink(obj) - }) - }) - .catch(function (error) { - clearInterval(self.checker) - LNbits.utils.notifyApiError(error) - }) - }, - closeFormDialog: function () { - this.formDialog.data = { - is_unique: false - } - }, - simplecloseFormDialog: function () { - this.simpleformDialog.data = { - is_unique: false - } - }, - openQrCodeDialog: function (linkId) { - var link = _.findWhere(this.withdrawLinks, {id: linkId}) - - this.qrCodeDialog.data = _.clone(link) - console.log(this.qrCodeDialog.data) - this.qrCodeDialog.data.url = - window.location.protocol + '//' + window.location.host - this.qrCodeDialog.show = true - }, - openUpdateDialog: function (linkId) { - var link = _.findWhere(this.withdrawLinks, {id: linkId}) - this.formDialog.data = _.clone(link._data) - this.formDialog.show = true - }, - sendFormData: function () { - var wallet = _.findWhere(this.g.user.wallets, { - id: this.formDialog.data.wallet - }) - var data = _.omit(this.formDialog.data, 'wallet') - - data.wait_time = - data.wait_time * - { - seconds: 1, - minutes: 60, - hours: 3600 - }[this.formDialog.secondMultiplier] - - if (data.id) { - this.updateWithdrawLink(wallet, data) - } else { - this.createWithdrawLink(wallet, data) - } - }, - simplesendFormData: function () { - var wallet = _.findWhere(this.g.user.wallets, { - id: this.simpleformDialog.data.wallet - }) - var data = _.omit(this.simpleformDialog.data, 'wallet') - - data.wait_time = 1 - data.min_withdrawable = data.max_withdrawable - data.title = 'vouchers' - data.is_unique = true - - if (data.id) { - this.updateWithdrawLink(wallet, data) - } else { - this.createWithdrawLink(wallet, data) - } - }, - updateWithdrawLink: function (wallet, data) { - var self = this - - LNbits.api - .request( - 'PUT', - '/withdraw/api/v1/links/' + data.id, - wallet.adminkey, - _.pick( - data, - 'title', - 'min_withdrawable', - 'max_withdrawable', - 'uses', - 'wait_time', - 'is_unique' - ) - ) - .then(function (response) { - self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { - return obj.id === data.id - }) - self.withdrawLinks.push(mapWithdrawLink(response.data)) - self.formDialog.show = false - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - }, - createWithdrawLink: function (wallet, data) { - var self = this - - LNbits.api - .request('POST', '/withdraw/api/v1/links', wallet.adminkey, data) - .then(function (response) { - self.withdrawLinks.push(mapWithdrawLink(response.data)) - self.formDialog.show = false - self.simpleformDialog.show = false - }) - .catch(function (error) { - LNbits.utils.notifyApiError(error) - }) - }, - deleteWithdrawLink: function (linkId) { - var self = this - var link = _.findWhere(this.withdrawLinks, {id: linkId}) - - LNbits.utils - .confirmDialog('Are you sure you want to delete this withdraw link?') - .onOk(function () { - LNbits.api - .request( - 'DELETE', - '/withdraw/api/v1/links/' + linkId, - _.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey - ) - .then(function (response) { - self.withdrawLinks = _.reject(self.withdrawLinks, function (obj) { - return obj.id === linkId - }) - }) - .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) { - var getWithdrawLinks = this.getWithdrawLinks - getWithdrawLinks() - this.checker = setInterval(function () { - getWithdrawLinks() - }, 20000) - } - } -}) diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html deleted file mode 100644 index 484464ba..00000000 --- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html +++ /dev/null @@ -1,199 +0,0 @@ - - - - - GET /withdraw/api/v1/links -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [<withdraw_link_object>, ...] -
Curl example
- curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - GET - /withdraw/api/v1/links/<withdraw_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 201 CREATED (application/json) -
- {"lnurl": <string>} -
Curl example
- curl -X GET {{ request.url_root }}api/v1/links/<withdraw_id> -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" - -
-
-
- - - - POST /withdraw/api/v1/links -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
- {"title": <string>, "min_withdrawable": <integer>, - "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>} -
- Returns 201 CREATED (application/json) -
- {"lnurl": <string>} -
Curl example
- curl -X POST {{ request.url_root }}api/v1/links -d '{"title": - <string>, "min_withdrawable": <integer>, - "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - -
-
-
- - - - PUT - /withdraw/api/v1/links/<withdraw_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Body (application/json)
- {"title": <string>, "min_withdrawable": <integer>, - "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>} -
- Returns 200 OK (application/json) -
- {"lnurl": <string>} -
Curl example
- curl -X PUT {{ request.url_root }}api/v1/links/<withdraw_id> -d - '{"title": <string>, "min_withdrawable": <integer>, - "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" - -
-
-
- - - - DELETE - /withdraw/api/v1/links/<withdraw_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.url_root }}api/v1/links/<withdraw_id> - -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}" - -
-
-
- - - - GET - /withdraw/api/v1/links/<the_hash>/<lnurl_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 201 CREATED (application/json) -
- {"status": <bool>} -
Curl example
- curl -X GET {{ request.url_root - }}api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" - -
-
-
- - - - GET - /withdraw/img/<lnurl_id> -
Curl example
- curl -X GET {{ request.url_root }}/withdraw/img/<lnurl_id>" - -
-
-
-
diff --git a/lnbits/extensions/withdraw/templates/withdraw/_lnurl.html b/lnbits/extensions/withdraw/templates/withdraw/_lnurl.html deleted file mode 100644 index efb9a486..00000000 --- a/lnbits/extensions/withdraw/templates/withdraw/_lnurl.html +++ /dev/null @@ -1,29 +0,0 @@ - - - -

- WARNING: LNURL must be used over https or TOR
- LNURL is a range of lightning-network standards that allow us to use - lightning-network differently. An LNURL withdraw is the permission for - someone to pull a certain amount of funds from a lightning wallet. In - this extension time is also added - an amount can be withdraw over a - period of time. A typical use case for an LNURL withdraw is a faucet, - although it is a very powerful technology, with much further reaching - implications. For example, an LNURL withdraw could be minted to pay for - a subscription service. -

-

- Exploring LNURL and finding use cases, is really helping inform - lightning protocol development, rather than the protocol dictating how - lightning-network should be engaged with. -

- Check - Awesome LNURL - for further information. -
-
-
diff --git a/lnbits/extensions/withdraw/templates/withdraw/display.html b/lnbits/extensions/withdraw/templates/withdraw/display.html deleted file mode 100644 index f4d6ef9d..00000000 --- a/lnbits/extensions/withdraw/templates/withdraw/display.html +++ /dev/null @@ -1,59 +0,0 @@ -{% extends "public.html" %} {% block page %} -
-
- - -
- {% if link.is_spent %} - Withdraw is spent. - {% endif %} - - - - - -
-
- Copy LNURL -
-
-
-
-
- - -
- LNbits LNURL-withdraw link -
-

- Use a LNURL compatible bitcoin wallet to claim the sats. -

-
- - - {% include "withdraw/_lnurl.html" %} - -
-
-
-{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html deleted file mode 100644 index 3cdabb3b..00000000 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ /dev/null @@ -1,356 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} {% block page %} -
-
- - - Quick vouchers - Advanced withdraw link(s) - - - - - -
-
-
Withdraw links
-
-
- Export to CSV -
-
- - {% raw %} - - - {% endraw %} - -
-
-
- -
- - -
- {{SITE_TITLE}} LNURL-withdraw extension -
-
- - - - {% include "withdraw/_api_docs.html" %} - - {% include "withdraw/_lnurl.html" %} - - -
-
- - - - - - - - - - -
-
- - -
-
- - -
-
- - - - - - - Use unique withdraw QR codes to reduce - `assmilking` - This is recommended if you are sharing the links on social - media or print QR codes. - - - -
- Update withdraw link - Create withdraw link - Cancel -
-
-
-
- - - - - - - - - -
- Create vouchers - Cancel -
-
-
-
- - - - - - {% raw %} - -

- ID: {{ qrCodeDialog.data.id }}
- Unique: {{ qrCodeDialog.data.is_unique }} - (QR code will change after each withdrawal)
- Max. withdrawable: {{ - qrCodeDialog.data.max_withdrawable }} sat
- Wait time: {{ qrCodeDialog.data.wait_time }} seconds
- Withdraws: {{ qrCodeDialog.data.used }} / {{ - qrCodeDialog.data.uses }} - -

- {% endraw %} -
- Copy LNURL - Shareable link - - Close -
-
-
-
-{% endblock %} diff --git a/lnbits/extensions/withdraw/templates/withdraw/print_qr.html b/lnbits/extensions/withdraw/templates/withdraw/print_qr.html deleted file mode 100644 index df4ca7d7..00000000 --- a/lnbits/extensions/withdraw/templates/withdraw/print_qr.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "print.html" %} {% block page %} - -
-
- {% for page in link %} - - - {% for threes in page %} - - {% for one in threes %} - - {% endfor %} - - {% endfor %} -
-
- -
-
-
- {% endfor %} -
-
-{% endblock %} {% block styles %} - -{% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py deleted file mode 100644 index 28f25756..00000000 --- a/lnbits/extensions/withdraw/views.py +++ /dev/null @@ -1,63 +0,0 @@ -from quart import g, abort, render_template -from http import HTTPStatus -import pyqrcode -from io import BytesIO -from lnbits.decorators import check_user_exists, validate_uuids - -from . import withdraw_ext -from .crud import get_withdraw_link, chunks - - -@withdraw_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("withdraw/index.html", user=g.user) - - -@withdraw_ext.route("/") -async def display(link_id): - link = await get_withdraw_link(link_id, 0) or abort( - HTTPStatus.NOT_FOUND, "Withdraw link does not exist." - ) - return await render_template("withdraw/display.html", link=link, unique=True) - - -@withdraw_ext.route("/img/") -async def img(link_id): - link = await get_withdraw_link(link_id, 0) or abort( - HTTPStatus.NOT_FOUND, "Withdraw link does not exist." - ) - qr = pyqrcode.create(link.lnurl) - stream = BytesIO() - qr.svg(stream, scale=3) - return ( - stream.getvalue(), - 200, - { - "Content-Type": "image/svg+xml", - "Cache-Control": "no-cache, no-store, must-revalidate", - "Pragma": "no-cache", - "Expires": "0", - }, - ) - - -@withdraw_ext.route("/print/") -async def print_qr(link_id): - link = await get_withdraw_link(link_id) or abort( - HTTPStatus.NOT_FOUND, "Withdraw link does not exist." - ) - if link.uses == 0: - return await render_template("withdraw/print_qr.html", link=link, unique=False) - links = [] - count = 0 - for x in link.usescsv.split(","): - linkk = await get_withdraw_link(link_id, count) or abort( - HTTPStatus.NOT_FOUND, "Withdraw link does not exist." - ) - links.append(str(linkk.lnurl)) - count = count + 1 - page_link = list(chunks(links, 2)) - linked = list(chunks(page_link, 5)) - return await render_template("withdraw/print_qr.html", link=linked, unique=True) diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py deleted file mode 100644 index 4979b932..00000000 --- a/lnbits/extensions/withdraw/views_api.py +++ /dev/null @@ -1,144 +0,0 @@ -from quart import g, jsonify, request -from http import HTTPStatus -from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore - -from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request - -from . import withdraw_ext -from .crud import ( - create_withdraw_link, - get_withdraw_link, - get_withdraw_links, - update_withdraw_link, - delete_withdraw_link, - create_hash_check, - get_hash_check, -) - - -@withdraw_ext.route("/api/v1/links", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_links(): - wallet_ids = [g.wallet.id] - - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids - try: - return ( - jsonify( - [ - { - **link._asdict(), - **{"lnurl": link.lnurl}, - } - for link in await get_withdraw_links(wallet_ids) - ] - ), - HTTPStatus.OK, - ) - except LnurlInvalidUrl: - return ( - jsonify( - { - "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." - } - ), - HTTPStatus.UPGRADE_REQUIRED, - ) - - -@withdraw_ext.route("/api/v1/links/", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_link_retrieve(link_id): - link = await get_withdraw_link(link_id, 0) - - if not link: - return ( - jsonify({"message": "Withdraw link does not exist."}), - HTTPStatus.NOT_FOUND, - ) - - if link.wallet != g.wallet.id: - return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN - - return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK - - -@withdraw_ext.route("/api/v1/links", methods=["POST"]) -@withdraw_ext.route("/api/v1/links/", methods=["PUT"]) -@api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "title": {"type": "string", "empty": False, "required": True}, - "min_withdrawable": {"type": "integer", "min": 1, "required": True}, - "max_withdrawable": {"type": "integer", "min": 1, "required": True}, - "uses": {"type": "integer", "min": 1, "required": True}, - "wait_time": {"type": "integer", "min": 1, "required": True}, - "is_unique": {"type": "boolean", "required": True}, - } -) -async def api_link_create_or_update(link_id=None): - if g.data["max_withdrawable"] < g.data["min_withdrawable"]: - return ( - jsonify( - { - "message": "`max_withdrawable` needs to be at least `min_withdrawable`." - } - ), - HTTPStatus.BAD_REQUEST, - ) - - usescsv = "" - for i in range(g.data["uses"]): - if g.data["is_unique"]: - usescsv += "," + str(i + 1) - else: - usescsv += "," + str(1) - usescsv = usescsv[1:] - - if link_id: - link = await get_withdraw_link(link_id, 0) - if not link: - return ( - jsonify({"message": "Withdraw link does not exist."}), - HTTPStatus.NOT_FOUND, - ) - if link.wallet != g.wallet.id: - return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN - link = await update_withdraw_link(link_id, **g.data, usescsv=usescsv, used=0) - else: - link = await create_withdraw_link( - wallet_id=g.wallet.id, **g.data, usescsv=usescsv - ) - - return ( - jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), - HTTPStatus.OK if link_id else HTTPStatus.CREATED, - ) - - -@withdraw_ext.route("/api/v1/links/", methods=["DELETE"]) -@api_check_wallet_key("admin") -async def api_link_delete(link_id): - link = await get_withdraw_link(link_id) - - if not link: - return ( - jsonify({"message": "Withdraw link does not exist."}), - HTTPStatus.NOT_FOUND, - ) - - if link.wallet != g.wallet.id: - return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN - - await delete_withdraw_link(link_id) - - return "", HTTPStatus.NO_CONTENT - - -@withdraw_ext.route("/api/v1/links//", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_hash_retrieve(the_hash, lnurl_id): - hashCheck = await get_hash_check(the_hash, lnurl_id) - return jsonify(hashCheck), HTTPStatus.OK