From 021cf64c1e82208b32200d491601c6c9cf2a5a56 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 23 Jun 2021 16:13:48 +0100 Subject: [PATCH 001/209] Added eclair backend --- .env.example | 6 +- lnbits/wallets/__init__.py | 1 + lnbits/wallets/eclair.py | 166 +++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 lnbits/wallets/eclair.py diff --git a/.env.example b/.env.example index cc70644c1..38add8fdd 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ LNBITS_FORCE_HTTPS=true LNBITS_SERVICE_FEE="0.0" # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, LndWallet (gRPC), -# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet +# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, EclairWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, # just so you can see the UI before dealing with this file. @@ -56,3 +56,7 @@ LNTXBOT_KEY=LNTXBOT_ADMIN_KEY # OpenNodeWallet OPENNODE_API_ENDPOINT=https://api.opennode.com/ OPENNODE_KEY=OPENNODE_ADMIN_KEY + +# EclairWallet +ECLAIR_URL=http://127.0.0.1:8080 +ECLAIR_PASS=eclair_password diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index 10a17c6fd..e1b37f6fa 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -9,3 +9,4 @@ from .lnpay import LNPayWallet from .lnbits import LNbitsWallet from .lndrest import LndRestWallet from .spark import SparkWallet +from .eclair import EclairWallet diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py new file mode 100644 index 000000000..2f29501c5 --- /dev/null +++ b/lnbits/wallets/eclair.py @@ -0,0 +1,166 @@ +import trio +import json +import httpx +import random +import base64 +import urllib.parse +from os import getenv +from typing import Optional, AsyncGenerator +from trio_websocket import open_websocket_url + +from .base import ( + StatusResponse, + InvoiceResponse, + PaymentResponse, + PaymentStatus, + Wallet, +) + +class EclairError(Exception): + pass + + +class UnknownError(Exception): + pass + + +class EclairWallet(Wallet): + def __init__(self): + url = getenv("ECLAIR_URL") + self.url = url[:-1] if url.endswith("/") else url + + passw = getenv("ECLAIR_PASS") + encodedAuth = base64.b64encode(f":{passw}".encode("utf-8")) + auth = str(encodedAuth, "utf-8") + self.auth = {"Authorization": f"Basic {auth}"} + + def __getattr__(self, key): + async def call(*args, **kwargs): + if args and kwargs: + raise TypeError( + f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}" + ) + elif args: + params = args + elif kwargs: + params = kwargs + else: + params = {} + + try: + async with httpx.AsyncClient() as client: + r = await client.post( + self.url + "/" + key, + headers=self.auth, + data=params, + timeout=40, + ) + except (OSError, httpx.ConnectError, httpx.RequestError) as exc: + raise UnknownError("error connecting to eclair: " + str(exc)) + + try: + data = r.json() + if "error" in data: + print('ERROR', data["error"]) + raise EclairError(data["error"]) + except: + raise UnknownError(r.text) + + #if r.error: + # print('ERROR', r) + # if r.status_code == 401: + # raise EclairError("Access key invalid!") + + #raise EclairError(data.error) + return data + + return call + + async def status(self) -> StatusResponse: + try: + funds = await self.usablebalances() + except (httpx.ConnectError, httpx.RequestError): + return StatusResponse("Couldn't connect to Eclair server", 0) + except (EclairError, UnknownError) as e: + return StatusResponse(str(e), 0) + if not funds: + return StatusResponse("Funding wallet has no funds", 0) + + return StatusResponse( + None, + funds[0]["canSend"] * 1000, + ) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + ) -> InvoiceResponse: + if description_hash: + raise Unsupported("description_hash") + + try: + r = await self.createinvoice( + amountMsat=amount * 1000, + description=memo or "", + exposeprivatechannels=True, + ) + ok, checking_id, payment_request, error_message = True, r["paymentHash"], r["serialized"], "" + except (EclairError, UnknownError) as e: + ok, payment_request, error_message = False, None, str(e) + + return InvoiceResponse(ok, checking_id, payment_request, error_message) + + async def pay_invoice(self, bolt11: str) -> PaymentResponse: + try: + r = await self.payinvoice(invoice=bolt11, blocking=True) + except (EclairError, UnknownError) as exc: + return PaymentResponse(False, None, 0, None, str(exc)) + + preimage = r["paymentPreimage"] + return PaymentResponse(True, r["paymentHash"], 0, preimage, None) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + try: + r = await self.getreceivedinfo(paymentHash=checking_id) + + except (EclairError, UnknownError): + return PaymentStatus(None) + + if r["status"]["type"] != "received": + return PaymentStatus(False) + return PaymentStatus(True) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + # check if it's 32 bytes hex + if len(checking_id) != 64: + return PaymentStatus(None) + try: + int(checking_id, 16) + except ValueError: + return PaymentStatus(None) + + try: + r = await self.getsentinfo(paymentHash=checking_id) + except (EclairError, UnknownError): + return PaymentStatus(None) + + raise KeyError("supplied an invalid checking_id") + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + url = urllib.parse.urlsplit(self.url) + ws_url = f"ws://{url.netloc}/ws" + + try: + async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + message = await ws.get_message() + if message["type"] == "payment-received": + print('Received message: %s' % message) + yield message["paymentHash"] + + except OSError as ose: + pass + + print("lost connection to eclair's websocket, retrying in 5 seconds") + await trio.sleep(5) From 4585d97324aa2ce7ea244c2b5ab35293a97634f7 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 23 Jun 2021 18:50:01 +0100 Subject: [PATCH 002/209] small fix on paid_invoices_stream --- lnbits/wallets/eclair.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 2f29501c5..9c8fa5405 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -152,15 +152,17 @@ class EclairWallet(Wallet): url = urllib.parse.urlsplit(self.url) ws_url = f"ws://{url.netloc}/ws" - try: - async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: - message = await ws.get_message() - if message["type"] == "payment-received": - print('Received message: %s' % message) - yield message["paymentHash"] + while True: + try: + async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + message = await ws.get_message() + if "payment-received" in message["type"]: + print('Received message: %s' % message) + yield message["paymentHash"] - except OSError as ose: - pass + except OSError as ose: + print('OSE', ose) + pass - print("lost connection to eclair's websocket, retrying in 5 seconds") - await trio.sleep(5) + print("lost connection to eclair's websocket, retrying in 5 seconds") + await trio.sleep(5) From 756b121105848b4042f3e54bc5857afd637a46d4 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 25 Jun 2021 10:15:50 +0100 Subject: [PATCH 003/209] print errors for tracking/debugging --- lnbits/wallets/eclair.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 9c8fa5405..59d2ffdf5 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -61,7 +61,7 @@ class EclairWallet(Wallet): try: data = r.json() if "error" in data: - print('ERROR', data["error"]) + print(f"ERROR-{key}", data["error"]) raise EclairError(data["error"]) except: raise UnknownError(r.text) @@ -156,8 +156,9 @@ class EclairWallet(Wallet): try: async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: message = await ws.get_message() + print('Received message: %s' % message) + if "payment-received" in message["type"]: - print('Received message: %s' % message) yield message["paymentHash"] except OSError as ose: From 165d663491e26671e9915cd91f184c1762e9a382 Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 00:36:11 +0100 Subject: [PATCH 004/209] Added freedom forum theme --- lnbits/static/scss/base.scss | 221 ++++++++++++++--------------------- lnbits/templates/base.html | 70 +++++------ 2 files changed, 121 insertions(+), 170 deletions(-) diff --git a/lnbits/static/scss/base.scss b/lnbits/static/scss/base.scss index afafd50d1..3668e7736 100644 --- a/lnbits/static/scss/base.scss +++ b/lnbits/static/scss/base.scss @@ -1,186 +1,143 @@ -$themes: ( - 'classic': ( - primary: #673ab7, - secondary: #9c27b0, - dark: #1f2234, - info: #333646, - marginal-bg: #1f2234, - marginal-text: #fff - ), - 'bitcoin': ( - primary: #ff9853, - secondary: #ff7353, - dark: #2d293b, - info: #333646, - marginal-bg: #2d293b, - marginal-text: #fff - ), - 'mint': ( - primary: #3ab77d, - secondary: #27b065, - dark: #1f342b, - info: #334642, - marginal-bg: #1f342b, - marginal-text: #fff - ), - 'autumn': ( - primary: #b7763a, - secondary: #b07927, - dark: #34291f, - info: #463f33, - marginal-bg: #342a1f, - marginal-text: rgb(255, 255, 255) - ), - 'flamingo': ( - primary: #d11d53, - secondary: #db3e6d, - dark: #803a45, - info: #ec7599, - marginal-bg: #803a45, - marginal-text: rgb(255, 255, 255) - ), - 'monochrome': ( - primary: #494949, - secondary: #6b6b6b, - dark: #000, - info: rgb(39, 39, 39), - marginal-bg: #000, - marginal-text: rgb(255, 255, 255) - ) -); - -@each $theme, $colors in $themes { - @each $name, $color in $colors { - @if $name == 'dark' { - [data-theme='#{$theme}'] .q-drawer--dark, - body[data-theme='#{$theme}'].body--dark, - [data-theme='#{$theme}'] .q-menu--dark { - background: $color !important; - } - - /* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE +$themes: ( 'classic': ( primary: #673ab7, secondary: #9c27b0, dark: #1f2234, info: #333646, marginal-bg: #1f2234, marginal-text: #fff), 'bitcoin': ( primary: #ff9853, secondary: #ff7353, dark: #2d293b, info: #333646, marginal-bg: #2d293b, marginal-text: #fff), 'freedom': ( primary: #e22156, secondary: #b91a45, dark: #0a0a0a, info: #1b1b1b, marginal-bg: #2d293b, marginal-text: #fff), 'mint': ( primary: #3ab77d, secondary: #27b065, dark: #1f342b, info: #334642, marginal-bg: #1f342b, marginal-text: #fff), 'autumn': ( primary: #b7763a, secondary: #b07927, dark: #34291f, info: #463f33, marginal-bg: #342a1f, marginal-text: rgb(255, 255, 255)), 'flamingo': ( primary: #d11d53, secondary: #db3e6d, dark: #803a45, info: #ec7599, marginal-bg: #803a45, marginal-text: rgb(255, 255, 255)), 'monochrome': ( primary: #494949, secondary: #6b6b6b, dark: #000, info: rgb(39, 39, 39), marginal-bg: #000, marginal-text: rgb(255, 255, 255))); +@each $theme, +$colors in $themes { + @each $name, + $color in $colors { + @if $name=='dark' { + [data-theme='#{$theme}'] .q-drawer--dark, + body[data-theme='#{$theme}'].body--dark, + [data-theme='#{$theme}'] .q-menu--dark { + background: $color !important; + } + /* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE // set a darker body bg for all themes, when in "dark mode" body[data-theme='#{$theme}'].body--dark { background: scale-color($color, $lightness: -60%); } */ + } + @if $name=='info' { + [data-theme='#{$theme}'] .q-card--dark, + [data-theme='#{$theme}'] .q-stepper--dark { + background: $color !important; + } + } } - @if $name == 'info' { - [data-theme='#{$theme}'] .q-card--dark, - [data-theme='#{$theme}'] .q-stepper--dark { - background: $color !important; - } + [data-theme='#{$theme}'] { + @each $name, + $color in $colors { + .bg-#{$name} { + background: $color !important; + } + .text-#{$name} { + color: $color !important; + } + } } - } - [data-theme='#{$theme}'] { - @each $name, $color in $colors { - .bg-#{$name} { - background: $color !important; - } - .text-#{$name} { - color: $color !important; - } - } - } } + +[data-theme='freedom'] .q-drawer--dark { + background: #0a0a0a !important; +} + +[data-theme='freedom'] .q-header { + background: #0a0a0a !important; +} + [data-theme='salvador'] .q-drawer--dark { - background: #242424 !important; + background: #242424 !important; } [data-theme='salvador'] .q-header { - background: #0f47af !important; + background: #0f47af !important; } [data-theme='flamingo'] .q-drawer--dark { - background: #e75480 !important; + background: #e75480 !important; } [data-theme='flamingo'] .q-header { - background: #e75480 !important; + background: #e75480 !important; } [v-cloak] { - display: none; + display: none; } body.body--dark .q-table--dark { - background: transparent; + background: transparent; } body.body--dark .q-field--error { - .text-negative, - .q-field__messages { - color: yellow !important; - } + .text-negative, + .q-field__messages { + color: yellow !important; + } } .lnbits-drawer__q-list .q-item { - padding-top: 5px !important; - padding-bottom: 5px !important; - border-top-right-radius: 3px; - border-bottom-right-radius: 3px; - - &.q-item--active { - color: inherit; - font-weight: bold; - } + padding-top: 5px !important; + padding-bottom: 5px !important; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; + &.q-item--active { + color: inherit; + font-weight: bold; + } } .lnbits__dialog-card { - width: 500px; + width: 500px; } .q-table--dense { - th:first-child, - td:first-child, - .q-table__bottom { - padding-left: 6px !important; - } - - th:last-child, - td:last-child, - .q-table__bottom { - padding-right: 6px !important; - } + th:first-child, + td:first-child, + .q-table__bottom { + padding-left: 6px !important; + } + th:last-child, + td:last-child, + .q-table__bottom { + padding-right: 6px !important; + } } a.inherit { - color: inherit; - text-decoration: none; + color: inherit; + text-decoration: none; } // QR video - video { - border-radius: 3px; + border-radius: 3px; } // Material icons font - @font-face { - font-family: 'Material Icons'; - font-style: normal; - font-weight: 400; - src: url(../fonts/material-icons-v50.woff2) format('woff2'); + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: url(../fonts/material-icons-v50.woff2) format('woff2'); } .material-icons { - font-family: 'Material Icons'; - font-weight: normal; - font-style: normal; - font-size: 24px; - line-height: 1; - letter-spacing: normal; - text-transform: none; - display: inline-block; - white-space: nowrap; - word-wrap: normal; - direction: ltr; - -moz-font-feature-settings: 'liga'; - -moz-osx-font-smoothing: grayscale; + font-family: 'Material Icons'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -moz-font-feature-settings: 'liga'; + -moz-osx-font-smoothing: grayscale; } // text-wrap .text-wrap { - word-break: break-word; -} + word-break: break-word; +} \ No newline at end of file diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index 965d5336e..610b2a75d 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -1,49 +1,33 @@ - - {% for url in VENDORED_CSS %} - - {% endfor %} - - - {% block styles %}{% endblock %} + + {% for url in VENDORED_CSS %} + {% endfor %} + + {% block styles %}{% endblock %} {% block title %}{{ SITE_TITLE }}{% endblock %} - + - - {% block head_scripts %}{% endblock %} - + {% block head_scripts %}{% endblock %} + - + - - - {% block drawer_toggle %} - - {% endblock %} - - - {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{ - SITE_TITLE }} {% else %} LNbits {% endif %} {% - endblock %} - - {% block beta %} - - + + {% block drawer_toggle %} + + {% endblock %} + + + {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %} LNbits {% endif %} {% endblock %} + + {% block beta %} + + USE WITH CAUTION - {{SITE_TITLE}} wallet is still in BETA @@ -118,6 +102,16 @@ size="md" >elSalvador + Freedom + {% block scripts %}{% endblock %} - + \ No newline at end of file From 1874d7fd5488e9dbd12cc5fcf7694ff2185b7de4 Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 10:42:55 +0100 Subject: [PATCH 005/209] TPoS now theme dependent --- .../extensions/tpos/templates/tpos/tpos.html | 455 ++++++++---------- 1 file changed, 192 insertions(+), 263 deletions(-) diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html index d05fab4e4..d14449895 100644 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -1,278 +1,207 @@ -{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock -%} {% block footer %}{% endblock %} {% block page_container %} +{% 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 -
-
-
+ + +
+
+

{% 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 %} +{% endblock %} \ No newline at end of file From 8042dc51b30d76580b8c0b7a944491ba81cc012c Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 11:16:00 +0100 Subject: [PATCH 006/209] prettier tpos --- .../extensions/tpos/templates/tpos/tpos.html | 523 +++++++++++------- 1 file changed, 332 insertions(+), 191 deletions(-) diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html index d14449895..49d88140f 100644 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -1,207 +1,348 @@ -{% extends "public.html" %} {% block toolbar_title %}{{ tpos.name }}{% endblock %} {% block footer %}{% endblock %} {% block page_container %} +{% 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 -
-
-
+ + +
+
+

{% 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 %} \ No newline at end of file +{% endblock %} From a36ad05fe0b376868f0b5a429abbd4110aff0abe Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 11:20:39 +0100 Subject: [PATCH 007/209] Added .env for theme --- .env.example | 4 +-- lnbits/templates/base.html | 57 ++++++++++++++++++++++++-------------- 2 files changed, 38 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index ab7f80961..4849fd063 100644 --- a/.env.example +++ b/.env.example @@ -32,8 +32,8 @@ LNBITS_SERVICE_FEE="0.0" LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TAGLINE="free and open-source lightning wallet" LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'" -# Choose from mint, flamingo, salvador, autumn, monochrome, classic -LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, mint, autumn, monochrome, salvador" +# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic +LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, # LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index 610b2a75d..8ceb44a9f 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -1,33 +1,48 @@ - - + {% for url in VENDORED_CSS %} - {% endfor %} + + {% endfor %} - {% block styles %}{% endblock %} + + {% block styles %}{% endblock %} {% block title %}{{ SITE_TITLE }}{% endblock %} - + - {% block head_scripts %}{% endblock %} - + + {% block head_scripts %}{% endblock %} + - + - - - {% block drawer_toggle %} - - {% endblock %} - - - {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{ SITE_TITLE }} {% else %} LNbits {% endif %} {% endblock %} - - {% block beta %} - - + + {% block drawer_toggle %} + + {% endblock %} + + + {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{ + SITE_TITLE }} {% else %} LNbits {% endif %} {% + endblock %} + + {% block beta %} + + USE WITH CAUTION - {{SITE_TITLE}} wallet is still in BETA @@ -207,4 +222,4 @@ {% block scripts %}{% endblock %} - \ No newline at end of file + From 2fd12e1d19942d3d11f4f585c5d18ddc9d2bc5f3 Mon Sep 17 00:00:00 2001 From: benarc Date: Wed, 20 Apr 2022 11:23:11 +0100 Subject: [PATCH 008/209] Changed theme picker color --- lnbits/templates/base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html index 8ceb44a9f..d61a0ccfb 100644 --- a/lnbits/templates/base.html +++ b/lnbits/templates/base.html @@ -123,7 +123,7 @@ flat @click="changeColor('freedom')" icon="format_color_fill" - color="pink-1" + color="pink-13" size="md" >Freedom
From d77e304e127ffb5e3eb6933e3166bb8a43a54f82 Mon Sep 17 00:00:00 2001 From: Ashkan Jalali <55811147+ashkanjalaliQ@users.noreply.github.com> Date: Thu, 28 Apr 2022 17:07:58 +0430 Subject: [PATCH 009/209] Update _api_docs.html add "webhook" and "unit" arguments to api document --- lnbits/core/templates/core/_api_docs.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/core/templates/core/_api_docs.html b/lnbits/core/templates/core/_api_docs.html index 0e74f38e4..5df37676e 100644 --- a/lnbits/core/templates/core/_api_docs.html +++ b/lnbits/core/templates/core/_api_docs.html @@ -48,7 +48,7 @@ {"X-Api-Key": "{{ wallet.inkey }}"}
Body (application/json)
{"out": false, "amount": <int>, "memo": <string>}{"out": false, "amount": <int>, "memo": <string>, "unit": <string>, "webhook": <url:string>}
Returns 201 CREATED (application/json) From 2cd6626ca1c59d99a445426eba6922c0e00f7e4c Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 29 Apr 2022 11:39:27 +0100 Subject: [PATCH 010/209] get status --- lnbits/wallets/__init__.py | 14 +++++++------- lnbits/wallets/eclair.py | 25 ++++++++++++++----------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index f3f251037..8a2ca1a54 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -1,12 +1,12 @@ # flake8: noqa -from .void import VoidWallet from .clightning import CLightningWallet -from .lntxbot import LntxbotWallet -from .opennode import OpenNodeWallet -from .lnpay import LNPayWallet -from .lnbits import LNbitsWallet -from .lndrest import LndRestWallet -from .spark import SparkWallet from .eclair import EclairWallet from .fake import FakeWallet +from .lnbits import LNbitsWallet +from .lndrest import LndRestWallet +from .lnpay import LNPayWallet +from .lntxbot import LntxbotWallet +from .opennode import OpenNodeWallet +from .spark import SparkWallet +from .void import VoidWallet diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 59d2ffdf5..9b5aca7de 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -1,21 +1,24 @@ -import trio -import json -import httpx -import random +import asyncio import base64 +import json +import random import urllib.parse from os import getenv -from typing import Optional, AsyncGenerator -from trio_websocket import open_websocket_url +from typing import AsyncGenerator, Optional + +import httpx +from websockets import connect from .base import ( - StatusResponse, InvoiceResponse, PaymentResponse, PaymentStatus, + StatusResponse, + Unsupported, Wallet, ) + class EclairError(Exception): pass @@ -154,11 +157,11 @@ class EclairWallet(Wallet): while True: try: - async with open_websocket_url(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: - message = await ws.get_message() + async with connect(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + message = await ws.recv() print('Received message: %s' % message) - if "payment-received" in message["type"]: + if "type" in message and "payment-received" in message.type: yield message["paymentHash"] except OSError as ose: @@ -166,4 +169,4 @@ class EclairWallet(Wallet): pass print("lost connection to eclair's websocket, retrying in 5 seconds") - await trio.sleep(5) + await asyncio.sleep(5) From 96df280d49e13d090b3f336e504c698dc2a3c79e Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 5 May 2022 15:44:34 +0100 Subject: [PATCH 011/209] adding fees --- lnbits/wallets/eclair.py | 232 ++++++++++++++++++++++----------------- 1 file changed, 130 insertions(+), 102 deletions(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 9b5aca7de..e2ba7d369 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -1,20 +1,23 @@ import asyncio import base64 import json -import random import urllib.parse from os import getenv -from typing import AsyncGenerator, Optional +from typing import AsyncGenerator, Dict, Optional import httpx from websockets import connect +from websockets.exceptions import ( + ConnectionClosed, + ConnectionClosedError, + ConnectionClosedOK, +) from .base import ( InvoiceResponse, PaymentResponse, PaymentStatus, StatusResponse, - Unsupported, Wallet, ) @@ -26,73 +29,37 @@ class EclairError(Exception): class UnknownError(Exception): pass - class EclairWallet(Wallet): def __init__(self): url = getenv("ECLAIR_URL") self.url = url[:-1] if url.endswith("/") else url + self.ws_url = f"ws://{urllib.parse.urlsplit(self.url).netloc}/ws" + passw = getenv("ECLAIR_PASS") encodedAuth = base64.b64encode(f":{passw}".encode("utf-8")) auth = str(encodedAuth, "utf-8") self.auth = {"Authorization": f"Basic {auth}"} - def __getattr__(self, key): - async def call(*args, **kwargs): - if args and kwargs: - raise TypeError( - f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}" - ) - elif args: - params = args - elif kwargs: - params = kwargs - else: - params = {} - - try: - async with httpx.AsyncClient() as client: - r = await client.post( - self.url + "/" + key, - headers=self.auth, - data=params, - timeout=40, - ) - except (OSError, httpx.ConnectError, httpx.RequestError) as exc: - raise UnknownError("error connecting to eclair: " + str(exc)) - - try: - data = r.json() - if "error" in data: - print(f"ERROR-{key}", data["error"]) - raise EclairError(data["error"]) - except: - raise UnknownError(r.text) - - #if r.error: - # print('ERROR', r) - # if r.status_code == 401: - # raise EclairError("Access key invalid!") - - #raise EclairError(data.error) - return data - - return call async def status(self) -> StatusResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/usablebalances", + headers=self.auth, + timeout=40 + ) try: - funds = await self.usablebalances() - except (httpx.ConnectError, httpx.RequestError): - return StatusResponse("Couldn't connect to Eclair server", 0) - except (EclairError, UnknownError) as e: - return StatusResponse(str(e), 0) - if not funds: - return StatusResponse("Funding wallet has no funds", 0) + data = r.json() + except: + return StatusResponse( + f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0 + ) + + if r.is_error: + return StatusResponse(data["error"], 0) - return StatusResponse( - None, - funds[0]["canSend"] * 1000, - ) + return StatusResponse(None, data[0]["canSend"] * 1000) async def create_invoice( self, @@ -100,73 +67,134 @@ class EclairWallet(Wallet): memo: Optional[str] = None, description_hash: Optional[bytes] = None, ) -> InvoiceResponse: + + data: Dict = {"amountMsat": amount * 1000} if description_hash: - raise Unsupported("description_hash") + data["description_hash"] = description_hash.hex() + else: + data["description"] = memo or "" - try: - r = await self.createinvoice( - amountMsat=amount * 1000, - description=memo or "", - exposeprivatechannels=True, - ) - ok, checking_id, payment_request, error_message = True, r["paymentHash"], r["serialized"], "" - except (EclairError, UnknownError) as e: - ok, payment_request, error_message = False, None, str(e) + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/createinvoice", + headers=self.auth, + data=data, + timeout=40 + ) - return InvoiceResponse(ok, checking_id, payment_request, error_message) + if r.is_error: + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass - async def pay_invoice(self, bolt11: str) -> PaymentResponse: - try: - r = await self.payinvoice(invoice=bolt11, blocking=True) - except (EclairError, UnknownError) as exc: - return PaymentResponse(False, None, 0, None, str(exc)) + return InvoiceResponse(False, None, None, error_message) + + data = r.json() + return InvoiceResponse(True, data["paymentHash"], data["serialized"], None) + + + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/payinvoice", + headers=self.auth, + data={"invoice": bolt11, "blocking": True}, + timeout=40, + ) + + if "error" in r.json(): + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass + return PaymentResponse(False, None, 0, None, error_message) + + data = r.json() + + + checking_id = data["paymentHash"] + preimage = data["paymentPreimage"] + + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/getsentinfo", + headers=self.auth, + data={"paymentHash": checking_id}, + timeout=40, + ) + + if "error" in r.json(): + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass + return PaymentResponse(False, None, 0, None, error_message) + + data = r.json() + fees = [i["status"] for i in data] + fee_msat = sum([i["feesPaid"] for i in fees]) + + return PaymentResponse(True, checking_id, fee_msat, preimage, None) + - preimage = r["paymentPreimage"] - return PaymentResponse(True, r["paymentHash"], 0, preimage, None) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: - try: - r = await self.getreceivedinfo(paymentHash=checking_id) + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.url}/getreceivedinfo", + headers=self.auth, + data={"paymentHash": checking_id} + ) + data = r.json() - except (EclairError, UnknownError): + if r.is_error or "error" in data: return PaymentStatus(None) - if r["status"]["type"] != "received": + if data["status"]["type"] != "received": return PaymentStatus(False) - return PaymentStatus(True) + + return PaymentStatus(True) async def get_payment_status(self, checking_id: str) -> PaymentStatus: - # check if it's 32 bytes hex - if len(checking_id) != 64: - return PaymentStatus(None) - try: - int(checking_id, 16) - except ValueError: - return PaymentStatus(None) + async with httpx.AsyncClient() as client: + r = await client.post( + url=f"{self.url}/getsentinfo", + headers=self.auth, + data={"paymentHash": checking_id} - try: - r = await self.getsentinfo(paymentHash=checking_id) - except (EclairError, UnknownError): - return PaymentStatus(None) + ) - raise KeyError("supplied an invalid checking_id") + data = r.json()[0] + + if r.is_error: + return PaymentStatus(None) + + if data["status"]["type"] != "sent": + return PaymentStatus(False) + + return PaymentStatus(True) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - url = urllib.parse.urlsplit(self.url) - ws_url = f"ws://{url.netloc}/ws" - - while True: - try: - async with connect(ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + + try: + async with connect(self.ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + while True: message = await ws.recv() - print('Received message: %s' % message) + message = json.loads(message) - if "type" in message and "payment-received" in message.type: + if message and message["type"] == "payment-received": yield message["paymentHash"] - except OSError as ose: - print('OSE', ose) - pass + except (OSError, ConnectionClosedOK, ConnectionClosedError, ConnectionClosed) as ose: + print('OSE', ose) + pass print("lost connection to eclair's websocket, retrying in 5 seconds") await asyncio.sleep(5) From 1695d0ce0ea5c3791eab90d717b000cd1bae9358 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 5 May 2022 15:47:25 +0100 Subject: [PATCH 012/209] send 0 fees when not available --- lnbits/wallets/eclair.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index e2ba7d369..04fd498f2 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -135,7 +135,7 @@ class EclairWallet(Wallet): except: error_message = r.text pass - return PaymentResponse(False, None, 0, None, error_message) + return PaymentResponse(True, checking_id, 0, preimage, error_message) ## ?? is this ok ?? data = r.json() fees = [i["status"] for i in data] From e0504a4e0c16d64e91984bdde83a71ee686e8564 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 5 May 2022 15:55:01 +0100 Subject: [PATCH 013/209] add eclair variables to .env example --- .env.example | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 4849fd063..14a87d02e 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,7 @@ LNBITS_SITE_DESCRIPTION="Some description about your service, will display if ti LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" # Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, -# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet +# LndRestWallet, CLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, # just so you can see the UI before dealing with this file. @@ -77,4 +77,8 @@ OPENNODE_KEY=OPENNODE_ADMIN_KEY # FakeWallet FAKE_WALLET_SECRET="ToTheMoon1" -LNBITS_DENOMINATION=sats \ No newline at end of file +LNBITS_DENOMINATION=sats + +# EclairWallet +ECLAIR_URL=http://127.0.0.1:8283 +ECLAIR_PASS=eclairpw \ No newline at end of file From 0c089109b925eefcc3f9e4c1985b34c5102d5ac8 Mon Sep 17 00:00:00 2001 From: Bitcoin Gamer 21 <98273902+bitcoingamer21@users.noreply.github.com> Date: Fri, 6 May 2022 20:26:42 +0200 Subject: [PATCH 014/209] Edited discord bot extension to use FastAPI (#570) Squashed 9 commits: * Added Discordbot Extension * Removed backup files * Fixing renaming wallet * added further description * further description index.html * import staticfiles * remove duplicate file * deleted changes in core --- lnbits/extensions/discordbot/Pipfile | 11 + lnbits/extensions/discordbot/README.md | 34 ++ lnbits/extensions/discordbot/__init__.py | 25 + lnbits/extensions/discordbot/config.json | 6 + lnbits/extensions/discordbot/crud.py | 123 +++++ lnbits/extensions/discordbot/migrations.py | 30 ++ lnbits/extensions/discordbot/models.py | 36 ++ lnbits/extensions/discordbot/static/stack.png | Bin 0 -> 73993 bytes .../templates/discordbot/_api_docs.html | 260 ++++++++++ .../templates/discordbot/index.html | 464 ++++++++++++++++++ lnbits/extensions/discordbot/views.py | 15 + lnbits/extensions/discordbot/views_api.py | 127 +++++ 12 files changed, 1131 insertions(+) create mode 100644 lnbits/extensions/discordbot/Pipfile create mode 100644 lnbits/extensions/discordbot/README.md create mode 100644 lnbits/extensions/discordbot/__init__.py create mode 100644 lnbits/extensions/discordbot/config.json create mode 100644 lnbits/extensions/discordbot/crud.py create mode 100644 lnbits/extensions/discordbot/migrations.py create mode 100644 lnbits/extensions/discordbot/models.py create mode 100644 lnbits/extensions/discordbot/static/stack.png create mode 100644 lnbits/extensions/discordbot/templates/discordbot/_api_docs.html create mode 100644 lnbits/extensions/discordbot/templates/discordbot/index.html create mode 100644 lnbits/extensions/discordbot/views.py create mode 100644 lnbits/extensions/discordbot/views_api.py diff --git a/lnbits/extensions/discordbot/Pipfile b/lnbits/extensions/discordbot/Pipfile new file mode 100644 index 000000000..d58206622 --- /dev/null +++ b/lnbits/extensions/discordbot/Pipfile @@ -0,0 +1,11 @@ +[[source]] +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] + +[requires] +python_version = "3.9" diff --git a/lnbits/extensions/discordbot/README.md b/lnbits/extensions/discordbot/README.md new file mode 100644 index 000000000..a14083175 --- /dev/null +++ b/lnbits/extensions/discordbot/README.md @@ -0,0 +1,34 @@ +# Discord Bot + +## Provide LNbits wallets for all your Discord users + +_This extension is a modifed version of LNbits [User Manager](../usermanager/README.md)_ + +The intended usage of this extension is to connect it to a specifically designed [Discord Bot](https://github.com/chrislennon/lnbits-discord-bot) leveraging LNbits as a community based lightning node. + +## Setup +This bot can target [lnbits.com](https://lnbits.com) or a self hosted instance. + +To setup and run the bot instructions are located [here](https://github.com/chrislennon/lnbits-discord-bot#installation) + +## Usage +This bot will allow users to interact with it in the following ways [full command list](https://github.com/chrislennon/lnbits-discord-bot#commands): + +`/create` Will create a wallet for the Discord user + - (currently limiting 1 Discord user == 1 LNbits user == 1 user wallet) + +![create](https://imgur.com/CWdDusE.png) + +`/balance` Will show the balance of the users wallet. + +![balance](https://imgur.com/tKeReCp.png) + +`/tip @user [amount]` Will sent money from one user to another + - If the recieving user does not have a wallet, one will be created for them + - The receiving user will receive a direct message from the bot with a link to their wallet + +![tip](https://imgur.com/K3tnChK.png) + +`/payme [amount] [description]` Will open an invoice that can be paid by any user + +![payme](https://imgur.com/dFvAqL3.png) diff --git a/lnbits/extensions/discordbot/__init__.py b/lnbits/extensions/discordbot/__init__.py new file mode 100644 index 000000000..ff60dd628 --- /dev/null +++ b/lnbits/extensions/discordbot/__init__.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_discordbot") + +discordbot_static_files = [ + { + "path": "/discordbot/static", + "app": StaticFiles(directory="lnbits/extensions/discordbot/static"), + "name": "discordbot_static", + } +] + +discordbot_ext: APIRouter = APIRouter(prefix="/discordbot", tags=["discordbot"]) + + +def discordbot_renderer(): + return template_renderer(["lnbits/extensions/discordbot/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/discordbot/config.json b/lnbits/extensions/discordbot/config.json new file mode 100644 index 000000000..eb6741225 --- /dev/null +++ b/lnbits/extensions/discordbot/config.json @@ -0,0 +1,6 @@ +{ + "name": "Discord Bot", + "short_description": "Generate users and wallets", + "icon": "person_add", + "contributors": ["bitcoingamer21"] +} diff --git a/lnbits/extensions/discordbot/crud.py b/lnbits/extensions/discordbot/crud.py new file mode 100644 index 000000000..5661fcb4d --- /dev/null +++ b/lnbits/extensions/discordbot/crud.py @@ -0,0 +1,123 @@ +from typing import List, Optional + +from lnbits.core.crud import ( + create_account, + create_wallet, + delete_wallet, + get_payments, + get_user, +) +from lnbits.core.models import Payment + +from . import db +from .models import CreateUserData, Users, Wallets + +### Users + + +async def create_discordbot_user(data: CreateUserData) -> 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=data.wallet_name) + + await db.execute( + """ + INSERT INTO discordbot.users (id, name, admin, discord_id) + VALUES (?, ?, ?, ?) + """, + (user.id, data.user_name, data.admin_id, data.discord_id), + ) + + await db.execute( + """ + INSERT INTO discordbot.wallets (id, admin, name, "user", adminkey, inkey) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + wallet.id, + data.admin_id, + data.wallet_name, + user.id, + wallet.adminkey, + wallet.inkey, + ), + ) + + user_created = await get_discordbot_user(user.id) + assert user_created, "Newly created user couldn't be retrieved" + return user_created + + +async def get_discordbot_user(user_id: str) -> Optional[Users]: + row = await db.fetchone("SELECT * FROM discordbot.users WHERE id = ?", (user_id,)) + return Users(**row) if row else None + + +async def get_discordbot_users(user_id: str) -> List[Users]: + rows = await db.fetchall( + "SELECT * FROM discordbot.users WHERE admin = ?", (user_id,) + ) + + return [Users(**row) for row in rows] + + +async def delete_discordbot_user(user_id: str) -> None: + wallets = await get_discordbot_wallets(user_id) + for wallet in wallets: + await delete_wallet(user_id=user_id, wallet_id=wallet.id) + + await db.execute("DELETE FROM discordbot.users WHERE id = ?", (user_id,)) + await db.execute("""DELETE FROM discordbot.wallets WHERE "user" = ?""", (user_id,)) + + +### Wallets + + +async def create_discordbot_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 discordbot.wallets (id, admin, name, "user", adminkey, inkey) + VALUES (?, ?, ?, ?, ?, ?) + """, + (wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey), + ) + wallet_created = await get_discordbot_wallet(wallet.id) + assert wallet_created, "Newly created wallet couldn't be retrieved" + return wallet_created + + +async def get_discordbot_wallet(wallet_id: str) -> Optional[Wallets]: + row = await db.fetchone( + "SELECT * FROM discordbot.wallets WHERE id = ?", (wallet_id,) + ) + return Wallets(**row) if row else None + + +async def get_discordbot_wallets(admin_id: str) -> Optional[Wallets]: + rows = await db.fetchall( + "SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,) + ) + return [Wallets(**row) for row in rows] + + +async def get_discordbot_users_wallets(user_id: str) -> Optional[Wallets]: + rows = await db.fetchall( + """SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,) + ) + return [Wallets(**row) for row in rows] + + +async def get_discordbot_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_discordbot_wallet(wallet_id: str, user_id: str) -> None: + await delete_wallet(user_id=user_id, wallet_id=wallet_id) + await db.execute("DELETE FROM discordbot.wallets WHERE id = ?", (wallet_id,)) diff --git a/lnbits/extensions/discordbot/migrations.py b/lnbits/extensions/discordbot/migrations.py new file mode 100644 index 000000000..ababfd7a5 --- /dev/null +++ b/lnbits/extensions/discordbot/migrations.py @@ -0,0 +1,30 @@ +async def m001_initial(db): + """ + Initial users table. + """ + await db.execute( + """ + CREATE TABLE discordbot.users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + admin TEXT NOT NULL, + discord_id TEXT + ); + """ + ) + + """ + Initial wallets table. + """ + await db.execute( + """ + CREATE TABLE discordbot.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/discordbot/models.py b/lnbits/extensions/discordbot/models.py new file mode 100644 index 000000000..4be367f80 --- /dev/null +++ b/lnbits/extensions/discordbot/models.py @@ -0,0 +1,36 @@ +from sqlite3 import Row + +from fastapi.param_functions import Query +from pydantic import BaseModel +from typing import Optional + + +class CreateUserData(BaseModel): + user_name: str = Query(...) + wallet_name: str = Query(...) + admin_id: str = Query(...) + discord_id: str = Query("") + +class CreateUserWallet(BaseModel): + user_id: str = Query(...) + wallet_name: str = Query(...) + admin_id: str = Query(...) + + +class Users(BaseModel): + id: str + name: str + admin: str + discord_id: str + +class Wallets(BaseModel): + 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/discordbot/static/stack.png b/lnbits/extensions/discordbot/static/stack.png new file mode 100644 index 0000000000000000000000000000000000000000..3b987db118ad79196f3592038cc77b1c4fa4ad92 GIT binary patch literal 73993 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 KQgPoZ?EeAyL$~4p literal 0 HcmV?d00001 diff --git a/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html b/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html new file mode 100644 index 000000000..40fcfb126 --- /dev/null +++ b/lnbits/extensions/discordbot/templates/discordbot/_api_docs.html @@ -0,0 +1,260 @@ + + + +
+ Discord Bot: Connect Discord users to LNbits. +
+
+ + + + + + + + GET + /discordbot/api/v1/users +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON list of users +
Curl example
+ curl -X GET {{ request.base_url }}discordbot/api/v1/users -H + "X-Api-Key: {{ user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /discordbot/api/v1/users/<user_id> +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ JSON list of users +
Curl example
+ curl -X GET {{ request.base_url + }}discordbot/api/v1/users/<user_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /discordbot/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.base_url + }}discordbot/api/v1/wallets/<user_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + GET + /discordbot/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.base_url + }}discordbot/api/v1/wallets<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + POST + /discordbot/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>,"discord_id": <string>} +
+ Returns 201 CREATED (application/json) +
+ {"id": <string>, "name": <string>, "admin": + <string>, "discord_id": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}discordbot/api/v1/users -d + '{"admin_id": "{{ user.id }}", "wallet_name": <string>, + "user_name": <string>, "discord_id": <string>}' -H "X-Api-Key: {{ + user.wallets[0].inkey }}" -H "Content-type: application/json" + +
+
+
+ + + + POST + /discordbot/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.base_url }}discordbot/api/v1/wallets -d + '{"user_id": <string>, "wallet_name": <string>, + "admin_id": "{{ user.id }}"}' -H "X-Api-Key: {{ user.wallets[0].inkey + }}" -H "Content-type: application/json" + +
+
+
+ + + + DELETE + /discordbot/api/v1/users/<user_id> +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X DELETE {{ request.base_url + }}discordbot/api/v1/users/<user_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + DELETE + /discordbot/api/v1/wallets/<wallet_id> +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X DELETE {{ request.base_url + }}discordbot/api/v1/wallets/<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" + +
+
+
+ + + + POST + /discordbot/api/v1/extensions +
Headers
+ {"X-Api-Key": <string>} +
Curl example
+ curl -X POST {{ request.base_url }}discordbot/api/v1/extensions -d + '{"userid": <string>, "extension": <string>, "active": + <integer>}' -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H + "Content-type: application/json" + +
+
+
+
diff --git a/lnbits/extensions/discordbot/templates/discordbot/index.html b/lnbits/extensions/discordbot/templates/discordbot/index.html new file mode 100644 index 000000000..782f8bb61 --- /dev/null +++ b/lnbits/extensions/discordbot/templates/discordbot/index.html @@ -0,0 +1,464 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+ This extension is designed to be used through its API by a Discord Bot, + currently you have to install the bot + yourself
+ + Soon™ there will be a much easier one-click install discord bot... +
+
+ + + +
+
+
Users
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Wallets
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
LNbits Discord Bot Extension + +
+
+ + + {% include "discordbot/_api_docs.html" %} + +
+
+ + + + + + + + + Create User + Cancel + + + + + + + + + + + Create Wallet + Cancel + + + +
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/discordbot/views.py b/lnbits/extensions/discordbot/views.py new file mode 100644 index 000000000..a5395e21f --- /dev/null +++ b/lnbits/extensions/discordbot/views.py @@ -0,0 +1,15 @@ +from fastapi import Request +from fastapi.params import Depends +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import discordbot_ext, discordbot_renderer + + +@discordbot_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return discordbot_renderer().TemplateResponse( + "discordbot/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/discordbot/views_api.py b/lnbits/extensions/discordbot/views_api.py new file mode 100644 index 000000000..64d1df1a7 --- /dev/null +++ b/lnbits/extensions/discordbot/views_api.py @@ -0,0 +1,127 @@ +from http import HTTPStatus + +from fastapi import Query +from fastapi.params import Depends +from starlette.exceptions import HTTPException + +from lnbits.core import update_user_extension +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type + +from . import discordbot_ext +from .crud import ( + create_discordbot_user, + create_discordbot_wallet, + delete_discordbot_user, + delete_discordbot_wallet, + get_discordbot_user, + get_discordbot_users, + get_discordbot_users_wallets, + get_discordbot_wallet, + get_discordbot_wallet_transactions, + get_discordbot_wallets, +) +from .models import CreateUserData, CreateUserWallet + +# Users + + +@discordbot_ext.get("/api/v1/users", status_code=HTTPStatus.OK) +async def api_discordbot_users(wallet: WalletTypeInfo = Depends(get_key_type)): + user_id = wallet.wallet.user + return [user.dict() for user in await get_discordbot_users(user_id)] + + +@discordbot_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK) +async def api_discordbot_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)): + user = await get_discordbot_user(user_id) + return user.dict() + + +@discordbot_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED) +async def api_discordbot_users_create( + data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type) +): + user = await create_discordbot_user(data) + full = user.dict() + full["wallets"] = [ + wallet.dict() for wallet in await get_discordbot_users_wallets(user.id) + ] + return full + + +@discordbot_ext.delete("/api/v1/users/{user_id}") +async def api_discordbot_users_delete( + user_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + user = await get_discordbot_user(user_id) + if not user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." + ) + await delete_discordbot_user(user_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +# Activate Extension + + +@discordbot_ext.post("/api/v1/extensions") +async def api_discordbot_activate_extension( + extension: str = Query(...), userid: str = Query(...), active: bool = Query(...) +): + user = await get_user(userid) + if not user: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="User does not exist." + ) + update_user_extension(user_id=userid, extension=extension, active=active) + return {"extension": "updated"} + + +# Wallets + + +@discordbot_ext.post("/api/v1/wallets") +async def api_discordbot_wallets_create( + data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type) +): + user = await create_discordbot_wallet( + user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id + ) + return user.dict() + + +@discordbot_ext.get("/api/v1/wallets") +async def api_discordbot_wallets(wallet: WalletTypeInfo = Depends(get_key_type)): + admin_id = wallet.wallet.user + return [wallet.dict() for wallet in await get_discordbot_wallets(admin_id)] + + +@discordbot_ext.get("/api/v1/transactions/{wallet_id}") +async def api_discordbot_wallet_transactions( + wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + return await get_discordbot_wallet_transactions(wallet_id) + + +@discordbot_ext.get("/api/v1/wallets/{user_id}") +async def api_discordbot_users_wallets( + user_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + return [ + s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id) + ] + + +@discordbot_ext.delete("/api/v1/wallets/{wallet_id}") +async def api_discordbot_wallets_delete( + wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) +): + get_wallet = await get_discordbot_wallet(wallet_id) + if not get_wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist." + ) + await delete_discordbot_wallet(wallet_id, get_wallet.user) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) From 436c3dd51ab9347fd399328aaadd7c1f9681aabb Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Fri, 6 May 2022 20:10:22 -0300 Subject: [PATCH 015/209] fix: spark fee when pay_invoice pending. --- lnbits/wallets/spark.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lnbits/wallets/spark.py b/lnbits/wallets/spark.py index 00bf6d3cf..c039990ed 100644 --- a/lnbits/wallets/spark.py +++ b/lnbits/wallets/spark.py @@ -129,7 +129,9 @@ class SparkWallet(Wallet): if pay["status"] == "failed": return PaymentResponse(False, None, 0, None, str(exc)) elif pay["status"] == "pending": - return PaymentResponse(None, payment_hash, 0, None, None) + return PaymentResponse( + None, payment_hash, fee_limit_msat, None, None + ) elif pay["status"] == "complete": r = pay r["payment_preimage"] = pay["preimage"] From c04e1de4120ceea0bf648af8dcd5b4846d4f0dbe Mon Sep 17 00:00:00 2001 From: Joel Klabo Date: Sat, 7 May 2022 17:27:58 -0700 Subject: [PATCH 016/209] Fix typo in User Manager API Docs --- .../extensions/usermanager/templates/usermanager/_api_docs.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html index f3b1e8bdb..9d2901f67 100644 --- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html +++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html @@ -14,7 +14,7 @@ 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.
+ developers stack as the user and wallet manager.
Created by, Ben Arc From f08fbc2487948ef2fce951c5f55fa9bf714b76ff Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 10 May 2022 21:48:24 +0200 Subject: [PATCH 017/209] core: check_invoice_status for outgoing payments --- lnbits/core/services.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 3d54e218d..875f7644c 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -321,7 +321,10 @@ async def check_invoice_status( payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) if not payment: return PaymentStatus(None) - status = await WALLET.get_invoice_status(payment.checking_id) + if payment.is_out: + status = await WALLET.get_payment_status(payment.checking_id) + else: + status = await WALLET.get_invoice_status(payment.checking_id) if not payment.pending: return status if payment.is_out and status.failed: From ebd6d6d58b7b54d9171d0bd303b80a86525a264d Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 10 May 2022 22:00:50 +0200 Subject: [PATCH 018/209] key check --- lnbits/core/views/api.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 0e88b5c85..ff46f29ee 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -365,14 +365,13 @@ async def api_payments_sse( @core_app.get("/api/v1/payments/{payment_hash}") async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): - wallet = None - try: - if X_Api_Key.extra: - print("No key") - except: + if X_Api_Key is not None: wallet = await get_wallet_for_key(X_Api_Key) + else: + wallet = None payment = await get_standalone_payment(payment_hash) - await check_invoice_status(payment.wallet_id, payment_hash) + if payment: + await check_invoice_status(payment.wallet_id, payment_hash) payment = await get_standalone_payment(payment_hash) if not payment: raise HTTPException( From a0a2ecd42418a44b851316672788a235a598d71f Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 11 May 2022 16:30:15 +0100 Subject: [PATCH 019/209] protect listing and deleteing user and wallets --- .../usermanager/templates/usermanager/index.html | 8 ++++---- lnbits/extensions/usermanager/views_api.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lnbits/extensions/usermanager/templates/usermanager/index.html b/lnbits/extensions/usermanager/templates/usermanager/index.html index 6fbe9686d..da11ad44b 100644 --- a/lnbits/extensions/usermanager/templates/usermanager/index.html +++ b/lnbits/extensions/usermanager/templates/usermanager/index.html @@ -299,7 +299,7 @@ .request( 'GET', '/usermanager/api/v1/users', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(function (response) { self.users = response.data.map(function (obj) { @@ -362,7 +362,7 @@ .request( 'DELETE', '/usermanager/api/v1/users/' + userId, - self.g.user.wallets[0].inkey + self.g.user.wallets[0].adminkey ) .then(function (response) { self.users = _.reject(self.users, function (obj) { @@ -389,7 +389,7 @@ .request( 'GET', '/usermanager/api/v1/wallets', - this.g.user.wallets[0].inkey + this.g.user.wallets[0].adminkey ) .then(function (response) { self.wallets = response.data.map(function (obj) { @@ -447,7 +447,7 @@ .request( 'DELETE', '/usermanager/api/v1/wallets/' + userId, - self.g.user.wallets[0].inkey + self.g.user.wallets[0].adminkey ) .then(function (response) { self.wallets = _.reject(self.wallets, function (obj) { diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py index 8c6523859..a36b36bbb 100644 --- a/lnbits/extensions/usermanager/views_api.py +++ b/lnbits/extensions/usermanager/views_api.py @@ -6,7 +6,7 @@ from starlette.exceptions import HTTPException from lnbits.core import update_user_extension from lnbits.core.crud import get_user -from lnbits.decorators import WalletTypeInfo, get_key_type +from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key from . import usermanager_ext from .crud import ( @@ -27,7 +27,7 @@ from .models import CreateUserData, CreateUserWallet @usermanager_ext.get("/api/v1/users", status_code=HTTPStatus.OK) -async def api_usermanager_users(wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_usermanager_users(wallet: WalletTypeInfo = Depends(require_admin_key)): user_id = wallet.wallet.user return [user.dict() for user in await get_usermanager_users(user_id)] @@ -52,7 +52,7 @@ async def api_usermanager_users_create( @usermanager_ext.delete("/api/v1/users/{user_id}") async def api_usermanager_users_delete( - user_id, wallet: WalletTypeInfo = Depends(get_key_type) + user_id, wallet: WalletTypeInfo = Depends(require_admin_key) ): user = await get_usermanager_user(user_id) if not user: @@ -93,7 +93,7 @@ async def api_usermanager_wallets_create( @usermanager_ext.get("/api/v1/wallets") -async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_usermanager_wallets(wallet: WalletTypeInfo = Depends(require_admin_key)): admin_id = wallet.wallet.user return [wallet.dict() for wallet in await get_usermanager_wallets(admin_id)] @@ -107,7 +107,7 @@ async def api_usermanager_wallet_transactions( @usermanager_ext.get("/api/v1/wallets/{user_id}") async def api_usermanager_users_wallets( - user_id, wallet: WalletTypeInfo = Depends(get_key_type) + user_id, wallet: WalletTypeInfo = Depends(require_admin_key) ): return [ s_wallet.dict() for s_wallet in await get_usermanager_users_wallets(user_id) @@ -116,7 +116,7 @@ async def api_usermanager_users_wallets( @usermanager_ext.delete("/api/v1/wallets/{wallet_id}") async def api_usermanager_wallets_delete( - wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) + wallet_id, wallet: WalletTypeInfo = Depends(require_admin_key) ): get_wallet = await get_usermanager_wallet(wallet_id) if not get_wallet: From caa23e6c9d6b56626416f831dd5dc9b08ac7047c Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 16 May 2022 11:21:30 +0100 Subject: [PATCH 020/209] key issues fixed --- lnbits/core/static/js/wallet.js | 5 +++-- lnbits/core/views/api.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index 8e782c548..ed997ab43 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -617,11 +617,12 @@ new Vue({ }) }, updateWalletName: function () { + console.log(this.newName, this.g) let newName = this.newName + let adminkey = this.g.wallet.adminkey if (!newName || !newName.length) return - // let data = {name: newName} LNbits.api - .request('PUT', '/api/v1/wallet/' + newName, this.g.wallet.adminkey, {}) + .request('PUT', '/api/v1/wallet/' + newName, adminkey, {}) .then(res => { this.newName = '' this.$q.notify({ diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 0e88b5c85..3be3a460e 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Union from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import httpx -from fastapi import Query, Request, Header +from fastapi import Header, Query, Request from fastapi.exceptions import HTTPException from fastapi.param_functions import Depends from fastapi.params import Body @@ -23,7 +23,7 @@ from lnbits.decorators import ( WalletInvoiceKeyChecker, WalletTypeInfo, get_key_type, - require_admin_key + require_admin_key, ) from lnbits.helpers import url_for, urlsafe_short_hash from lnbits.requestvars import g @@ -99,7 +99,7 @@ async def api_update_balance( @core_app.put("/api/v1/wallet/{new_name}") async def api_update_wallet( - new_name: str, wallet: WalletTypeInfo = Depends(WalletAdminKeyChecker()) + new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key) ): await update_wallet(wallet.wallet.id, new_name) return { From c8aaf3377faac414f3a8ad97a373783f88107d40 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 16 May 2022 11:23:34 +0100 Subject: [PATCH 021/209] clean logs --- lnbits/core/static/js/wallet.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index ed997ab43..f872ad090 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -617,7 +617,6 @@ new Vue({ }) }, updateWalletName: function () { - console.log(this.newName, this.g) let newName = this.newName let adminkey = this.g.wallet.adminkey if (!newName || !newName.length) return From 98891b7680329d3caf4531a6cb736cf7cf35766e Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Tue, 17 May 2022 12:16:41 +0100 Subject: [PATCH 022/209] get bookmark promp fired on new wallet --- lnbits/core/static/js/wallet.js | 5 +---- lnbits/core/templates/core/wallet.html | 13 ++++++------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/lnbits/core/static/js/wallet.js b/lnbits/core/static/js/wallet.js index f872ad090..29a1025da 100644 --- a/lnbits/core/static/js/wallet.js +++ b/lnbits/core/static/js/wallet.js @@ -691,10 +691,7 @@ new Vue({ }, mounted: function () { // show disclaimer - if ( - this.$refs.disclaimer && - !this.$q.localStorage.getItem('lnbits.disclaimerShown') - ) { + if (!this.$q.localStorage.getItem('lnbits.disclaimerShown')) { this.disclaimerDialog.show = true this.$q.localStorage.set('lnbits.disclaimerShown', true) } diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index e9c6e496a..9453e1bf7 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -706,8 +706,7 @@ - {% if service_fee > 0 %} -
+
Warning
@@ -720,10 +719,10 @@

This service is in BETA, and we hold no responsibility for people losing - access to funds. To encourage you to run your own LNbits installation, - any balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} - will incur a charge of - {{ service_fee }}% service fee per week. + access to funds. {% if service_fee > 0 %} To encourage you to run your + own LNbits installation, any balance on {% raw %}{{ + disclaimerDialog.location.host }}{% endraw %} will incur a charge of + {{ service_fee }}% service fee per week. {% endif %}

- {% endif %} {% endblock %} + {% endblock %}
From bb5b04b307ec519536361f9309f57ad79e0bdc84 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 17 May 2022 21:55:39 +0100 Subject: [PATCH 023/209] Added max amount to withdraw creation --- lnbits/extensions/withdraw/models.py | 2 +- lnbits/extensions/withdraw/templates/withdraw/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index a03c7db87..72b75fb89 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -12,7 +12,7 @@ class CreateWithdrawData(BaseModel): title: str = Query(...) min_withdrawable: int = Query(..., ge=1) max_withdrawable: int = Query(..., ge=1) - uses: int = Query(..., ge=1) + uses: int = Query(..., ge=1, le=250) wait_time: int = Query(..., ge=1) is_unique: bool diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index 13673028d..c7c216ced 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -132,7 +132,7 @@ - +
From ca8f649fd63e71b8ce655bb50214dfb1a4a06f00 Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 17 May 2022 22:02:54 +0100 Subject: [PATCH 024/209] Added check for error message --- lnbits/extensions/withdraw/models.py | 2 +- lnbits/extensions/withdraw/views_api.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index 72b75fb89..a03c7db87 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -12,7 +12,7 @@ class CreateWithdrawData(BaseModel): title: str = Query(...) min_withdrawable: int = Query(..., ge=1) max_withdrawable: int = Query(..., ge=1) - uses: int = Query(..., ge=1, le=250) + uses: int = Query(..., ge=1) wait_time: int = Query(..., ge=1) is_unique: bool diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index 8dd9e3402..c48a26b75 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -71,6 +71,11 @@ async def api_link_create_or_update( link_id: str = None, wallet: WalletTypeInfo = Depends(require_admin_key), ): + if data.uses > 250: + raise HTTPException( + detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST + ) + if data.min_withdrawable < 1: raise HTTPException( detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST From d951ac435f29fbba049e174bae2b03ba85f3863e Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 17 May 2022 22:08:33 +0100 Subject: [PATCH 025/209] Added check to form --- lnbits/extensions/withdraw/templates/withdraw/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index c7c216ced..0ce8507b7 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -180,7 +180,7 @@ - +
Date: Thu, 19 May 2022 23:05:34 -0400 Subject: [PATCH 027/209] Fix GET transactions endpoint in usermanager docs --- .../usermanager/templates/usermanager/_api_docs.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html index 9d2901f67..34b3c39b0 100644 --- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html +++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html @@ -97,7 +97,7 @@ GET - /usermanager/api/v1/wallets<wallet_id>
Headers
{"X-Api-Key": <string>} @@ -109,7 +109,7 @@
Curl example
curl -X GET {{ request.base_url - }}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{ + }}usermanager/api/v1/transactions/<wallet_id> -H "X-Api-Key: {{ user.wallets[0].inkey }}"
From b4ad0e101d4a3ec139b099d573cd303c7460ca4b Mon Sep 17 00:00:00 2001 From: Arc <33088785+arcbtc@users.noreply.github.com> Date: Fri, 20 May 2022 14:35:00 +0100 Subject: [PATCH 028/209] Revert "Check invoice status outgoing" --- lnbits/core/services.py | 5 +---- lnbits/core/views/api.py | 11 ++++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lnbits/core/services.py b/lnbits/core/services.py index 875f7644c..3d54e218d 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -321,10 +321,7 @@ async def check_invoice_status( payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) if not payment: return PaymentStatus(None) - if payment.is_out: - status = await WALLET.get_payment_status(payment.checking_id) - else: - status = await WALLET.get_invoice_status(payment.checking_id) + status = await WALLET.get_invoice_status(payment.checking_id) if not payment.pending: return status if payment.is_out and status.failed: diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 85de97cd5..3be3a460e 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -365,13 +365,14 @@ async def api_payments_sse( @core_app.get("/api/v1/payments/{payment_hash}") async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): - if X_Api_Key is not None: + wallet = None + try: + if X_Api_Key.extra: + print("No key") + except: wallet = await get_wallet_for_key(X_Api_Key) - else: - wallet = None payment = await get_standalone_payment(payment_hash) - if payment: - await check_invoice_status(payment.wallet_id, payment_hash) + await check_invoice_status(payment.wallet_id, payment_hash) payment = await get_standalone_payment(payment_hash) if not payment: raise HTTPException( From b4a6b5a1d9e2ff9275cc068ce403ceeb5180bac1 Mon Sep 17 00:00:00 2001 From: calle <93376500+callebtc@users.noreply.github.com> Date: Wed, 1 Jun 2022 13:25:37 +0200 Subject: [PATCH 029/209] serviced fix doc (#645) --- docs/guide/installation.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 2806a4f5d..7184d46b5 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -78,17 +78,23 @@ Systemd is great for taking care of your LNbits instance. It will start it on bo [Unit] Description=LNbits -#Wants=lnd.service # you can uncomment these lines if you know what you're doing -#After=lnd.service # it will make sure that lnbits starts after lnd (replace with your own backend service) +# you can uncomment these lines if you know what you're doing +# it will make sure that lnbits starts after lnd (replace with your own backend service) +#Wants=lnd.service +#After=lnd.service [Service] -WorkingDirectory=/home/bitcoin/lnbits # replace with the absolute path of your lnbits installation -ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 # same here -User=bitcoin # replace with the user that you're running lnbits on +# replace with the absolute path of your lnbits installation +WorkingDirectory=/home/bitcoin/lnbits +# same here +ExecStart=/home/bitcoin/lnbits/venv/bin/uvicorn lnbits.__main__:app --port 5000 +# replace with the user that you're running lnbits on +User=bitcoin Restart=always TimeoutSec=120 RestartSec=30 -Environment=PYTHONUNBUFFERED=1 # this makes sure that you receive logs in real time +# this makes sure that you receive logs in real time +Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target From 7c4ce9bf961aa5f26ca8b9f2ab6ca85d7bee5388 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Wed, 1 Jun 2022 12:53:47 +0100 Subject: [PATCH 030/209] Extension: add example extension (#644) * add example ext files * remove from extension list --- lnbits/extensions/example/README.md | 11 ++++ lnbits/extensions/example/__init__.py | 19 ++++++ lnbits/extensions/example/example.config.json | 6 ++ lnbits/extensions/example/migrations.py | 10 ++++ lnbits/extensions/example/models.py | 6 ++ .../example/templates/example/index.html | 59 +++++++++++++++++++ lnbits/extensions/example/views.py | 18 ++++++ lnbits/extensions/example/views_api.py | 34 +++++++++++ 8 files changed, 163 insertions(+) create mode 100644 lnbits/extensions/example/README.md create mode 100644 lnbits/extensions/example/__init__.py create mode 100644 lnbits/extensions/example/example.config.json create mode 100644 lnbits/extensions/example/migrations.py create mode 100644 lnbits/extensions/example/models.py create mode 100644 lnbits/extensions/example/templates/example/index.html create mode 100644 lnbits/extensions/example/views.py create mode 100644 lnbits/extensions/example/views_api.py diff --git a/lnbits/extensions/example/README.md b/lnbits/extensions/example/README.md new file mode 100644 index 000000000..277294592 --- /dev/null +++ b/lnbits/extensions/example/README.md @@ -0,0 +1,11 @@ +

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 new file mode 100644 index 000000000..29a0f0b65 --- /dev/null +++ b/lnbits/extensions/example/__init__.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter + +from lnbits.db import Database +from lnbits.helpers import template_renderer + +db = Database("ext_example") + +example_ext: APIRouter = APIRouter( + prefix="/example", + tags=["example"] +) + + +def example_renderer(): + return template_renderer(["lnbits/extensions/example/templates"]) + + +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/example/example.config.json b/lnbits/extensions/example/example.config.json new file mode 100644 index 000000000..b8eec1939 --- /dev/null +++ b/lnbits/extensions/example/example.config.json @@ -0,0 +1,6 @@ +{ + "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 new file mode 100644 index 000000000..99d7c362d --- /dev/null +++ b/lnbits/extensions/example/migrations.py @@ -0,0 +1,10 @@ +# 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 new file mode 100644 index 000000000..0347a06f3 --- /dev/null +++ b/lnbits/extensions/example/models.py @@ -0,0 +1,6 @@ +# from pydantic import BaseModel + +# class Example(BaseModel): +# id: str +# wallet: str + diff --git a/lnbits/extensions/example/templates/example/index.html b/lnbits/extensions/example/templates/example/index.html new file mode 100644 index 000000000..d732ef376 --- /dev/null +++ b/lnbits/extensions/example/templates/example/index.html @@ -0,0 +1,59 @@ +{% 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 new file mode 100644 index 000000000..252b47263 --- /dev/null +++ b/lnbits/extensions/example/views.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI, Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import example_ext, example_renderer + +templates = Jinja2Templates(directory="templates") + + +@example_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return example_renderer().TemplateResponse( + "example/index.html", {"request": request, "user": user.dict()} + ) diff --git a/lnbits/extensions/example/views_api.py b/lnbits/extensions/example/views_api.py new file mode 100644 index 000000000..f144fe76d --- /dev/null +++ b/lnbits/extensions/example/views_api.py @@ -0,0 +1,34 @@ +# views_api.py is for you API endpoints that could be hit by another service + +# add your dependencies here + +# import httpx +# (use httpx just like requests, except instead of response.ok there's only the +# response.is_error that is its inverse) + +from . import example_ext + +# add your endpoints here + +@example_ext.get("/api/v1/tools") +async def api_example(): + """Try to add descriptions for others.""" + tools = [ + { + "name": "fastAPI", + "url": "https://fastapi.tiangolo.com/", + "language": "Python", + }, + { + "name": "Vue.js", + "url": "https://vuejs.org/", + "language": "JavaScript", + }, + { + "name": "Quasar Framework", + "url": "https://quasar.dev/", + "language": "JavaScript", + }, + ] + + return tools From f8400512f7ecaf1b8109e3c1655013451f859fb9 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Wed, 1 Jun 2022 14:53:05 +0200 Subject: [PATCH 031/209] black formating --- conv.py | 1 + lnbits/bolt11.py | 12 ++--- lnbits/core/views/api.py | 6 ++- lnbits/core/views/generic.py | 4 +- lnbits/decorators.py | 24 +++++++--- lnbits/extensions/discordbot/models.py | 2 + lnbits/extensions/discordbot/views_api.py | 4 +- lnbits/extensions/example/__init__.py | 5 +-- lnbits/extensions/example/models.py | 1 - lnbits/extensions/example/views_api.py | 1 + lnbits/extensions/jukebox/crud.py | 2 +- lnbits/extensions/jukebox/views_api.py | 3 +- lnbits/extensions/livestream/views.py | 1 + lnbits/extensions/offlineshop/helpers.py | 4 +- lnbits/extensions/paywall/views_api.py | 7 +-- lnbits/extensions/streamalerts/views_api.py | 2 +- lnbits/extensions/withdraw/lnurl.py | 20 ++++++--- lnbits/extensions/withdraw/views.py | 3 +- lnbits/extensions/withdraw/views_api.py | 6 +-- lnbits/helpers.py | 4 +- lnbits/settings.py | 4 +- lnbits/wallets/eclair.py | 50 ++++++++++----------- lnbits/wallets/macaroon/__init__.py | 2 +- lnbits/wallets/macaroon/macaroon.py | 16 ++++--- tests/conftest.py | 3 +- tests/core/views/test_generic.py | 1 + tests/extensions/bleskomat/conftest.py | 15 +++++-- tests/helpers.py | 5 ++- 28 files changed, 127 insertions(+), 81 deletions(-) diff --git a/conv.py b/conv.py index 159c7dc0e..aa66a998d 100644 --- a/conv.py +++ b/conv.py @@ -1,6 +1,7 @@ import psycopg2 import sqlite3 import os + # Python script to migrate an LNbits SQLite DB to Postgres # All credits to @Fritz446 for the awesome work diff --git a/lnbits/bolt11.py b/lnbits/bolt11.py index 74f739630..e52219840 100644 --- a/lnbits/bolt11.py +++ b/lnbits/bolt11.py @@ -165,7 +165,7 @@ def lnencode(addr, privkey): if addr.amount: amount = Decimal(str(addr.amount)) # We can only send down to millisatoshi. - if amount * 10 ** 12 % 10: + if amount * 10**12 % 10: raise ValueError( "Cannot encode {}: too many decimal places".format(addr.amount) ) @@ -270,7 +270,7 @@ class LnAddr(object): def shorten_amount(amount): """Given an amount in bitcoin, shorten it""" # Convert to pico initially - amount = int(amount * 10 ** 12) + amount = int(amount * 10**12) units = ["p", "n", "u", "m", ""] for unit in units: if amount % 1000 == 0: @@ -289,7 +289,7 @@ def _unshorten_amount(amount: str) -> int: # * `u` (micro): multiply by 0.000001 # * `n` (nano): multiply by 0.000000001 # * `p` (pico): multiply by 0.000000000001 - units = {"p": 10 ** 12, "n": 10 ** 9, "u": 10 ** 6, "m": 10 ** 3} + units = {"p": 10**12, "n": 10**9, "u": 10**6, "m": 10**3} unit = str(amount)[-1] # BOLT #11: @@ -348,9 +348,9 @@ def _trim_to_bytes(barr): def _readable_scid(short_channel_id: int) -> str: return "{blockheight}x{transactionindex}x{outputindex}".format( - blockheight=((short_channel_id >> 40) & 0xffffff), - transactionindex=((short_channel_id >> 16) & 0xffffff), - outputindex=(short_channel_id & 0xffff), + blockheight=((short_channel_id >> 40) & 0xFFFFFF), + transactionindex=((short_channel_id >> 16) & 0xFFFFFF), + outputindex=(short_channel_id & 0xFFFF), ) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 3be3a460e..3b2f8b3a2 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -391,7 +391,11 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)): return {"paid": False} if wallet and wallet.id == payment.wallet_id: - return {"paid": not payment.pending, "preimage": payment.preimage, "details": payment} + return { + "paid": not payment.pending, + "preimage": payment.preimage, + "details": payment, + } return {"paid": not payment.pending, "preimage": payment.preimage} diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 16a2fbac1..d9687e160 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -226,7 +226,9 @@ async def lnurl_balance_notify(request: Request, service: str): redeem_lnurl_withdraw(bc.wallet, bc.url) -@core_html_routes.get("/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet") +@core_html_routes.get( + "/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet" +) async def lnurlwallet(request: Request): async with db.connect() as conn: account = await create_account(conn=conn) diff --git a/lnbits/decorators.py b/lnbits/decorators.py index 76cb8a54b..d6f73f406 100644 --- a/lnbits/decorators.py +++ b/lnbits/decorators.py @@ -13,7 +13,11 @@ from starlette.requests import Request from lnbits.core.crud import get_user, get_wallet_for_key from lnbits.core.models import User, Wallet from lnbits.requestvars import g -from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS +from lnbits.settings import ( + LNBITS_ALLOWED_USERS, + LNBITS_ADMIN_USERS, + LNBITS_ADMIN_EXTENSIONS, +) class KeyChecker(SecurityBase): @@ -122,7 +126,7 @@ async def get_key_type( # 0: admin # 1: invoice # 2: invalid - pathname = r['path'].split('/')[1] + pathname = r["path"].split("/")[1] if not api_key_header and not api_key_query: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST) @@ -133,8 +137,12 @@ async def get_key_type( checker = WalletAdminKeyChecker(api_key=token) await checker.__call__(r) wallet = WalletTypeInfo(0, checker.wallet) - if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") + if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and ( + LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS + ): + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." + ) return wallet except HTTPException as e: if e.status_code == HTTPStatus.BAD_REQUEST: @@ -148,8 +156,12 @@ async def get_key_type( checker = WalletInvoiceKeyChecker(api_key=token) await checker.__call__(r) wallet = WalletTypeInfo(1, checker.wallet) - if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS): - raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.") + if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and ( + LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS + ): + raise HTTPException( + status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized." + ) return wallet except HTTPException as e: if e.status_code == HTTPStatus.BAD_REQUEST: diff --git a/lnbits/extensions/discordbot/models.py b/lnbits/extensions/discordbot/models.py index 4be367f80..985eb096f 100644 --- a/lnbits/extensions/discordbot/models.py +++ b/lnbits/extensions/discordbot/models.py @@ -11,6 +11,7 @@ class CreateUserData(BaseModel): admin_id: str = Query(...) discord_id: str = Query("") + class CreateUserWallet(BaseModel): user_id: str = Query(...) wallet_name: str = Query(...) @@ -23,6 +24,7 @@ class Users(BaseModel): admin: str discord_id: str + class Wallets(BaseModel): id: str admin: str diff --git a/lnbits/extensions/discordbot/views_api.py b/lnbits/extensions/discordbot/views_api.py index 64d1df1a7..6f213a89a 100644 --- a/lnbits/extensions/discordbot/views_api.py +++ b/lnbits/extensions/discordbot/views_api.py @@ -109,9 +109,7 @@ async def api_discordbot_wallet_transactions( async def api_discordbot_users_wallets( user_id, wallet: WalletTypeInfo = Depends(get_key_type) ): - return [ - s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id) - ] + return [s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id)] @discordbot_ext.delete("/api/v1/wallets/{wallet_id}") diff --git a/lnbits/extensions/example/__init__.py b/lnbits/extensions/example/__init__.py index 29a0f0b65..96cc6428c 100644 --- a/lnbits/extensions/example/__init__.py +++ b/lnbits/extensions/example/__init__.py @@ -5,10 +5,7 @@ from lnbits.helpers import template_renderer db = Database("ext_example") -example_ext: APIRouter = APIRouter( - prefix="/example", - tags=["example"] -) +example_ext: APIRouter = APIRouter(prefix="/example", tags=["example"]) def example_renderer(): diff --git a/lnbits/extensions/example/models.py b/lnbits/extensions/example/models.py index 0347a06f3..bfeb7517f 100644 --- a/lnbits/extensions/example/models.py +++ b/lnbits/extensions/example/models.py @@ -3,4 +3,3 @@ # class Example(BaseModel): # id: str # wallet: str - diff --git a/lnbits/extensions/example/views_api.py b/lnbits/extensions/example/views_api.py index f144fe76d..5b702717e 100644 --- a/lnbits/extensions/example/views_api.py +++ b/lnbits/extensions/example/views_api.py @@ -10,6 +10,7 @@ from . import example_ext # add your endpoints here + @example_ext.get("/api/v1/tools") async def api_example(): """Try to add descriptions for others.""" diff --git a/lnbits/extensions/jukebox/crud.py b/lnbits/extensions/jukebox/crud.py index caaac7e56..d160daeec 100644 --- a/lnbits/extensions/jukebox/crud.py +++ b/lnbits/extensions/jukebox/crud.py @@ -41,7 +41,7 @@ async def update_jukebox( q = ", ".join([f"{field[0]} = ?" for field in data]) items = [f"{field[1]}" for field in data] items.append(juke_id) - q = q.replace("user", '"user"', 1) # hack to make user be "user"! + q = q.replace("user", '"user"', 1) # hack to make user be "user"! await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items)) row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,)) return Jukebox(**row) if row else None diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py index 3ba8cbf25..1f3723a74 100644 --- a/lnbits/extensions/jukebox/views_api.py +++ b/lnbits/extensions/jukebox/views_api.py @@ -455,5 +455,6 @@ async def api_get_jukebox_currently( ) except: raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong, or no song is playing yet" + status_code=HTTPStatus.NOT_FOUND, + detail="Something went wrong, or no song is playing yet", ) diff --git a/lnbits/extensions/livestream/views.py b/lnbits/extensions/livestream/views.py index 4f1bd1c5c..ef0354315 100644 --- a/lnbits/extensions/livestream/views.py +++ b/lnbits/extensions/livestream/views.py @@ -1,4 +1,5 @@ from http import HTTPStatus + # from mmap import MAP_DENYWRITE from fastapi.param_functions import Depends diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py index db2c19cce..6b56cf559 100644 --- a/lnbits/extensions/offlineshop/helpers.py +++ b/lnbits/extensions/offlineshop/helpers.py @@ -8,8 +8,8 @@ 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 + offset = mac[-1] & 0x0F + binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF return str(binary)[-digits:].zfill(digits) diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py index 3d1c2575d..8052c63ba 100644 --- a/lnbits/extensions/paywall/views_api.py +++ b/lnbits/extensions/paywall/views_api.py @@ -54,8 +54,7 @@ async def api_paywall_delete( @paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}") async def api_paywall_create_invoice( - data: CreatePaywallInvoice, - paywall_id: str = Query(None) + data: CreatePaywallInvoice, paywall_id: str = Query(None) ): paywall = await get_paywall(paywall_id) if data.amount < paywall.amount: @@ -78,7 +77,9 @@ async def api_paywall_create_invoice( @paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}") -async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id: str = Query(None)): +async def api_paywal_check_invoice( + data: CheckPaywallInvoice, paywall_id: str = Query(None) +): paywall = await get_paywall(paywall_id) payment_hash = data.payment_hash if not paywall: diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py index 0a678d8ba..bb2998ee5 100644 --- a/lnbits/extensions/streamalerts/views_api.py +++ b/lnbits/extensions/streamalerts/views_api.py @@ -123,7 +123,7 @@ async def api_create_donation(data: CreateDonation, request: Request): completelinktext="Back to Stream!", webhook=webhook_base + "/streamalerts/api/v1/postdonation", description=description, - **charge_details + **charge_details, ) charge = await create_charge(user=charge_details["user"], data=create_charge_data) await create_donation( diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index 801fa62f6..859d8aaa2 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -30,7 +30,9 @@ async def api_lnurl_response(request: Request, unique_hash): ) if link.is_spent: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent.") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." + ) url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash) withdrawResponse = { "tag": "withdrawRequest", @@ -48,7 +50,11 @@ async def api_lnurl_response(request: Request, unique_hash): @withdraw_ext.get("/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback") async def api_lnurl_callback( - unique_hash, request: Request, k1: str = Query(...), pr: str = Query(...), id_unique_hash=None + unique_hash, + request: Request, + k1: str = Query(...), + pr: str = Query(...), + id_unique_hash=None, ): link = await get_withdraw_link_by_hash(unique_hash) now = int(datetime.now().timestamp()) @@ -58,7 +64,9 @@ async def api_lnurl_callback( ) if link.is_spent: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent.") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." + ) if link.k1 != k1: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Bad request.") @@ -81,7 +89,7 @@ async def api_lnurl_callback( if id_unique_hash == shortuuid.uuid(name=tohash): found = True useslist.pop(ind) - usescsv = ','.join(useslist) + usescsv = ",".join(useslist) if not found: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." @@ -134,7 +142,9 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash ) if link.is_spent: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent.") + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." + ) useslist = link.usescsv.split(",") found = False diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py index 81aeef98c..1f059a4b0 100644 --- a/lnbits/extensions/withdraw/views.py +++ b/lnbits/extensions/withdraw/views.py @@ -103,6 +103,7 @@ async def print_qr(request: Request, link_id): "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True} ) + @withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse) async def print_qr(request: Request, link_id): link = await get_withdraw_link(link_id) @@ -135,4 +136,4 @@ async def print_qr(request: Request, link_id): return withdraw_renderer().TemplateResponse( "withdraw/csv.html", {"request": request, "link": linked, "unique": True} - ) \ No newline at end of file + ) diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index c48a26b75..027b2925a 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -72,10 +72,8 @@ async def api_link_create_or_update( wallet: WalletTypeInfo = Depends(require_admin_key), ): if data.uses > 250: - raise HTTPException( - detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST - ) - + raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST) + if data.min_withdrawable < 1: raise HTTPException( detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST diff --git a/lnbits/helpers.py b/lnbits/helpers.py index cb6f8ee72..4ccfccead 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -26,7 +26,9 @@ class Extension(NamedTuple): class ExtensionManager: def __init__(self): self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS - self._admin_only: List[str] = [x.strip(' ') for x in settings.LNBITS_ADMIN_EXTENSIONS] + self._admin_only: List[str] = [ + x.strip(" ") for x in settings.LNBITS_ADMIN_EXTENSIONS + ] self._extension_folders: List[str] = [ x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions")) ][0] diff --git a/lnbits/settings.py b/lnbits/settings.py index 9ccd9e4eb..e3eb57e31 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -29,7 +29,9 @@ LNBITS_ALLOWED_USERS: List[str] = env.list( "LNBITS_ALLOWED_USERS", default=[], subcast=str ) LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str) -LNBITS_ADMIN_EXTENSIONS: List[str] = env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str) +LNBITS_ADMIN_EXTENSIONS: List[str] = env.list( + "LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str +) LNBITS_DISABLED_EXTENSIONS: List[str] = env.list( "LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str ) diff --git a/lnbits/wallets/eclair.py b/lnbits/wallets/eclair.py index 04fd498f2..aa7ddc395 100644 --- a/lnbits/wallets/eclair.py +++ b/lnbits/wallets/eclair.py @@ -29,6 +29,7 @@ class EclairError(Exception): class UnknownError(Exception): pass + class EclairWallet(Wallet): def __init__(self): url = getenv("ECLAIR_URL") @@ -41,13 +42,10 @@ class EclairWallet(Wallet): auth = str(encodedAuth, "utf-8") self.auth = {"Authorization": f"Basic {auth}"} - async def status(self) -> StatusResponse: async with httpx.AsyncClient() as client: r = await client.post( - f"{self.url}/usablebalances", - headers=self.auth, - timeout=40 + f"{self.url}/usablebalances", headers=self.auth, timeout=40 ) try: data = r.json() @@ -55,7 +53,7 @@ class EclairWallet(Wallet): return StatusResponse( f"Failed to connect to {self.url}, got: '{r.text[:200]}...'", 0 ) - + if r.is_error: return StatusResponse(data["error"], 0) @@ -76,10 +74,7 @@ class EclairWallet(Wallet): async with httpx.AsyncClient() as client: r = await client.post( - f"{self.url}/createinvoice", - headers=self.auth, - data=data, - timeout=40 + f"{self.url}/createinvoice", headers=self.auth, data=data, timeout=40 ) if r.is_error: @@ -95,7 +90,6 @@ class EclairWallet(Wallet): data = r.json() return InvoiceResponse(True, data["paymentHash"], data["serialized"], None) - async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: async with httpx.AsyncClient() as client: r = await client.post( @@ -113,10 +107,9 @@ class EclairWallet(Wallet): error_message = r.text pass return PaymentResponse(False, None, 0, None, error_message) - + data = r.json() - checking_id = data["paymentHash"] preimage = data["paymentPreimage"] @@ -135,22 +128,22 @@ class EclairWallet(Wallet): except: error_message = r.text pass - return PaymentResponse(True, checking_id, 0, preimage, error_message) ## ?? is this ok ?? + return PaymentResponse( + True, checking_id, 0, preimage, error_message + ) ## ?? is this ok ?? data = r.json() fees = [i["status"] for i in data] fee_msat = sum([i["feesPaid"] for i in fees]) - - return PaymentResponse(True, checking_id, fee_msat, preimage, None) - + return PaymentResponse(True, checking_id, fee_msat, preimage, None) async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: r = await client.post( f"{self.url}/getreceivedinfo", headers=self.auth, - data={"paymentHash": checking_id} + data={"paymentHash": checking_id}, ) data = r.json() @@ -160,31 +153,33 @@ class EclairWallet(Wallet): if data["status"]["type"] != "received": return PaymentStatus(False) - return PaymentStatus(True) + return PaymentStatus(True) async def get_payment_status(self, checking_id: str) -> PaymentStatus: async with httpx.AsyncClient() as client: r = await client.post( url=f"{self.url}/getsentinfo", headers=self.auth, - data={"paymentHash": checking_id} - + data={"paymentHash": checking_id}, ) data = r.json()[0] if r.is_error: return PaymentStatus(None) - + if data["status"]["type"] != "sent": return PaymentStatus(False) return PaymentStatus(True) async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: - + try: - async with connect(self.ws_url, extra_headers=[('Authorization', self.auth["Authorization"])]) as ws: + async with connect( + self.ws_url, + extra_headers=[("Authorization", self.auth["Authorization"])], + ) as ws: while True: message = await ws.recv() message = json.loads(message) @@ -192,8 +187,13 @@ class EclairWallet(Wallet): if message and message["type"] == "payment-received": yield message["paymentHash"] - except (OSError, ConnectionClosedOK, ConnectionClosedError, ConnectionClosed) as ose: - print('OSE', ose) + except ( + OSError, + ConnectionClosedOK, + ConnectionClosedError, + ConnectionClosed, + ) as ose: + print("OSE", ose) pass print("lost connection to eclair's websocket, retrying in 5 seconds") diff --git a/lnbits/wallets/macaroon/__init__.py b/lnbits/wallets/macaroon/__init__.py index b7cadcfeb..4967c1008 100644 --- a/lnbits/wallets/macaroon/__init__.py +++ b/lnbits/wallets/macaroon/__init__.py @@ -1 +1 @@ -from .macaroon import load_macaroon, AESCipher \ No newline at end of file +from .macaroon import load_macaroon, AESCipher diff --git a/lnbits/wallets/macaroon/macaroon.py b/lnbits/wallets/macaroon/macaroon.py index dd6ff6369..3548e9e29 100644 --- a/lnbits/wallets/macaroon/macaroon.py +++ b/lnbits/wallets/macaroon/macaroon.py @@ -5,10 +5,11 @@ from hashlib import md5 import getpass BLOCK_SIZE = 16 -import getpass +import getpass + def load_macaroon(macaroon: str) -> str: - """Returns hex version of a macaroon encoded in base64 or the file path. + """Returns hex version of a macaroon encoded in base64 or the file path. :param macaroon: Macaroon encoded in base64 or file path. :type macaroon: str @@ -29,6 +30,7 @@ def load_macaroon(macaroon: str) -> str: pass return macaroon + class AESCipher(object): """This class is compatible with crypto-js/aes.js @@ -39,6 +41,7 @@ class AESCipher(object): AES.decrypt(encrypted, password).toString(Utf8); """ + def __init__(self, key=None, description=""): self.key = key self.description = description + " " @@ -47,7 +50,6 @@ class AESCipher(object): length = BLOCK_SIZE - (len(data) % BLOCK_SIZE) return data + (chr(length) * length).encode() - def unpad(self, data): return data[: -(data[-1] if type(data[-1]) == int else ord(data[-1]))] @@ -70,8 +72,7 @@ class AESCipher(object): return final_key[:output] def decrypt(self, encrypted: str) -> str: - """Decrypts a string using AES-256-CBC. - """ + """Decrypts a string using AES-256-CBC.""" passphrase = self.passphrase encrypted = base64.b64decode(encrypted) assert encrypted[0:8] == b"Salted__" @@ -92,7 +93,10 @@ class AESCipher(object): key = key_iv[:32] iv = key_iv[32:] aes = AES.new(key, AES.MODE_CBC, iv) - return base64.b64encode(b"Salted__" + salt + aes.encrypt(self.pad(message))).decode() + return base64.b64encode( + b"Salted__" + salt + aes.encrypt(self.pad(message)) + ).decode() + # if this file is executed directly, ask for a macaroon and encrypt it if __name__ == "__main__": diff --git a/tests/conftest.py b/tests/conftest.py index 127233c15..27ba9137a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,9 +19,10 @@ def app(): loop.run_until_complete(loop.shutdown_asyncgens()) loop.close() + @pytest.fixture async def client(app): - client = AsyncClient(app=app, base_url=f'http://{HOST}:{PORT}') + client = AsyncClient(app=app, base_url=f"http://{HOST}:{PORT}") # yield and pass the client to the test yield client # close the async client after the test has finished diff --git a/tests/core/views/test_generic.py b/tests/core/views/test_generic.py index 4917cde41..5d3db0fb1 100644 --- a/tests/core/views/test_generic.py +++ b/tests/core/views/test_generic.py @@ -1,6 +1,7 @@ import pytest from tests.conftest import client + @pytest.mark.asyncio async def test_core_views_generic(client): response = await client.get("/") diff --git a/tests/extensions/bleskomat/conftest.py b/tests/extensions/bleskomat/conftest.py index 924998a71..265d3be0c 100644 --- a/tests/extensions/bleskomat/conftest.py +++ b/tests/extensions/bleskomat/conftest.py @@ -4,16 +4,22 @@ import secrets from lnbits.core.crud import create_account, create_wallet from lnbits.extensions.bleskomat.crud import create_bleskomat, create_bleskomat_lnurl from lnbits.extensions.bleskomat.models import CreateBleskomat -from lnbits.extensions.bleskomat.helpers import generate_bleskomat_lnurl_secret, generate_bleskomat_lnurl_signature, prepare_lnurl_params, query_to_signing_payload +from lnbits.extensions.bleskomat.helpers import ( + generate_bleskomat_lnurl_secret, + generate_bleskomat_lnurl_signature, + prepare_lnurl_params, + query_to_signing_payload, +) from lnbits.extensions.bleskomat.exchange_rates import exchange_rate_providers exchange_rate_providers["dummy"] = { "name": "dummy", "domain": None, "api_url": None, - "getter": lambda data, replacements: str(1e8),# 1 BTC = 100000000 sats + "getter": lambda data, replacements: str(1e8), # 1 BTC = 100000000 sats } + @pytest.fixture async def bleskomat(): user = await create_account() @@ -22,11 +28,12 @@ async def bleskomat(): name="Test Bleskomat", fiat_currency="EUR", exchange_rate_provider="dummy", - fee="0" + fee="0", ) bleskomat = await create_bleskomat(data=data, wallet_id=wallet.id) return bleskomat + @pytest.fixture async def lnurl(bleskomat): query = { @@ -43,7 +50,7 @@ async def lnurl(bleskomat): signature = generate_bleskomat_lnurl_signature( payload=payload, api_key_secret=bleskomat.api_key_secret, - api_key_encoding=bleskomat.api_key_encoding + api_key_encoding=bleskomat.api_key_encoding, ) secret = generate_bleskomat_lnurl_secret(bleskomat.api_key_id, signature) params = json.JSONEncoder().encode(params) diff --git a/tests/helpers.py b/tests/helpers.py index 1687e25d8..3774f6fc0 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -2,6 +2,7 @@ import hashlib import secrets from lnbits.core.crud import create_payment + async def credit_wallet(wallet_id: str, amount: int): preimage = secrets.token_hex(32) m = hashlib.sha256() @@ -14,6 +15,6 @@ async def credit_wallet(wallet_id: str, amount: int): checking_id=payment_hash, preimage=preimage, memo="", - amount=amount,# msat - pending=False,# not pending, so it will increase the wallet's balance + amount=amount, # msat + pending=False, # not pending, so it will increase the wallet's balance ) From 895d9d2e0aa60f075b768061b71bdd712ae9e21b Mon Sep 17 00:00:00 2001 From: Tomas Bezouska Date: Wed, 1 Jun 2022 15:24:17 +0200 Subject: [PATCH 032/209] Extension: LNURLw webhook_url (#610) * LNURLw web_hook * crlf -> lf * Fix typo * LNURLw webhook api doc --- lnbits/extensions/withdraw/crud.py | 6 +++-- lnbits/extensions/withdraw/lnurl.py | 25 +++++++++++++++++-- lnbits/extensions/withdraw/migrations.py | 6 +++++ lnbits/extensions/withdraw/models.py | 2 ++ lnbits/extensions/withdraw/static/js/index.js | 3 ++- .../templates/withdraw/_api_docs.html | 5 ++-- .../withdraw/templates/withdraw/index.html | 14 +++++++++++ 7 files changed, 54 insertions(+), 7 deletions(-) diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py index 18a057f34..9d55d2451 100644 --- a/lnbits/extensions/withdraw/crud.py +++ b/lnbits/extensions/withdraw/crud.py @@ -25,9 +25,10 @@ async def create_withdraw_link( unique_hash, k1, open_time, - usescsv + usescsv, + webhook_url ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( link_id, @@ -42,6 +43,7 @@ async def create_withdraw_link( urlsafe_short_hash(), int(datetime.now().timestamp()) + data.wait_time, usescsv, + data.webhook_url ), ) link = await get_withdraw_link(link_id, 0) diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index 801fa62f6..603e7dad1 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -1,4 +1,7 @@ import json +import traceback +import httpx + from datetime import datetime from http import HTTPStatus @@ -103,17 +106,35 @@ async def api_lnurl_callback( await update_withdraw_link(link.id, **changes) payment_request = pr - - await pay_invoice( + + payment_hash = await pay_invoice( wallet_id=link.wallet, payment_request=payment_request, max_sat=link.max_withdrawable, extra={"tag": "withdraw"}, ) + + if link.webhook_url: + async with httpx.AsyncClient() as client: + try: + r = await client.post( + link.webhook_url, + json={ + "payment_hash": payment_hash, + "payment_request": payment_request, + "lnurlw": link.id, + }, + timeout=40, + ) + except Exception as exc: + # webhook fails shouldn't cause the lnurlw to fail since invoice is already paid + print("Caught exception when dispatching webhook url:", exc) + return {"status": "OK"} except Exception as e: await update_withdraw_link(link.id, **changesback) + print(traceback.format_exc()) return {"status": "ERROR", "reason": "Link not working"} diff --git a/lnbits/extensions/withdraw/migrations.py b/lnbits/extensions/withdraw/migrations.py index 1a13aa6d5..5c527e79e 100644 --- a/lnbits/extensions/withdraw/migrations.py +++ b/lnbits/extensions/withdraw/migrations.py @@ -108,3 +108,9 @@ async def m003_make_hash_check(db): ); """ ) + +async def m004_webhook_url(db): + """ + Adds webhook_url + """ + await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN webhook_url TEXT;") \ No newline at end of file diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index a03c7db87..c3ca7c459 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -15,6 +15,7 @@ class CreateWithdrawData(BaseModel): uses: int = Query(..., ge=1) wait_time: int = Query(..., ge=1) is_unique: bool + webhook_url: str = Query(None) class WithdrawLink(BaseModel): @@ -32,6 +33,7 @@ class WithdrawLink(BaseModel): used: int = Query(0) usescsv: str = Query(None) number: int = Query(0) + webhook_url: str = Query(None) @property def is_spent(self) -> bool: diff --git a/lnbits/extensions/withdraw/static/js/index.js b/lnbits/extensions/withdraw/static/js/index.js index 91ff6446a..e54005c69 100644 --- a/lnbits/extensions/withdraw/static/js/index.js +++ b/lnbits/extensions/withdraw/static/js/index.js @@ -179,7 +179,8 @@ new Vue({ 'max_withdrawable', 'uses', 'wait_time', - 'is_unique' + 'is_unique', + 'webhook_url' ) ) .then(function (response) { diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html index c1172bcdb..76068fcb2 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html +++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html @@ -70,7 +70,8 @@ {"title": <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>}
Returns 201 CREATED (application/json) @@ -81,7 +82,7 @@ >curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title": <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>}' -H + "wait_time": <integer>, "is_unique": <boolean>, "webhook_url": <string>}' -H "Content-type: application/json" -H "X-Api-Key: {{ user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index 0ce8507b7..f6ea220df 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -29,6 +29,7 @@ {{ col.label }} + @@ -145,11 +145,13 @@ icon="launch" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" @click="ticketCard(props)" - > Click to show ticket + > Click to show ticket - {{ col.label == "Ticket" ? col.value.length > 20 ? `${col.value.substring(0, 20)}...` : col.value : col.value }} + {{ col.label == "Ticket" ? col.value.length > 20 ? + `${col.value.substring(0, 20)}...` : col.value : col.value }} @@ -410,7 +412,7 @@ }) }) }, - ticketCard(ticket){ + ticketCard(ticket) { this.ticketDialog.show = true let {date, email, ltext, name} = ticket.row this.ticketDialog.data = { @@ -469,7 +471,7 @@ }, updateformDialog: function (formId) { var link = _.findWhere(this.forms, {id: formId}) - console.log("LINK", link) + console.log('LINK', link) this.formDialog.data.id = link.id this.formDialog.data.wallet = link.wallet diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html index e255d4a02..d5b4b5b8e 100644 --- a/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html @@ -39,8 +39,8 @@ [<lnurldevice_object>, ...]
Curl example
curl -X POST {{ request.base_url }}lnurldevice/api/v1/lnurlpos -d '{"title": - <string>, "message":<string>, "currency": + >curl -X POST {{ request.base_url }}lnurldevice/api/v1/lnurlpos -d + '{"title": <string>, "message":<string>, "currency": <integer>}' -H "Content-type: application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}" @@ -104,8 +104,8 @@
Curl example
curl -X GET {{ request.base_url - }}lnurldevice/api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + }}lnurldevice/api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: + {{ user.wallets[0].inkey }}" @@ -159,8 +159,8 @@
Curl example
curl -X DELETE {{ request.base_url - }}lnurldevice/api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + }}lnurldevice/api/v1/lnurlpos/<lnurldevice_id> -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html index b51e25568..24d194843 100644 --- a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html @@ -480,12 +480,11 @@ updatedData ) .then(function (response) { - self.lnurldeviceLinks = _.reject( - self.lnurldeviceLinks, - function (obj) { - return obj.id === updatedData.id - } - ) + self.lnurldeviceLinks = _.reject(self.lnurldeviceLinks, function ( + obj + ) { + return obj.id === updatedData.id + }) self.lnurldeviceLinks.push(maplnurldevice(response.data)) self.formDialoglnurldevice.show = false self.clearFormDialoglnurldevice() diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html index bf920558b..200865fb1 100644 --- a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html +++ b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html @@ -17,8 +17,8 @@ [<pay_link_object>, ...]
Curl example
curl -X GET {{ request.base_url }}lnurlp/api/v1/links -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}lnurlp/api/v1/links -H "X-Api-Key: + {{ user.wallets[0].inkey }}" @@ -39,8 +39,8 @@ {"lnurl": <string>}
Curl example
curl -X GET {{ request.base_url }}lnurlp/api/v1/links/<pay_id> -H - "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}lnurlp/api/v1/links/<pay_id> + -H "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -68,11 +68,11 @@ {"lnurl": <string>}
Curl example
curl -X POST {{ request.base_url }}lnurlp/api/v1/links -d '{"description": - <string>, "amount": <integer>, "max": <integer>, - "min": <integer>, "comment_chars": <integer>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + >curl -X POST {{ request.base_url }}lnurlp/api/v1/links -d + '{"description": <string>, "amount": <integer>, "max": + <integer>, "min": <integer>, "comment_chars": + <integer>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" @@ -99,8 +99,8 @@ {"lnurl": <string>}
Curl example
curl -X PUT {{ request.base_url }}lnurlp/api/v1/links/<pay_id> -d - '{"description": <string>, "amount": <integer>}' -H + >curl -X PUT {{ request.base_url }}lnurlp/api/v1/links/<pay_id> + -d '{"description": <string>, "amount": <integer>}' -H "Content-type: application/json" -H "X-Api-Key: {{ user.wallets[0].adminkey }}" @@ -126,8 +126,9 @@
Curl example
curl -X DELETE {{ request.base_url }}lnurlp/api/v1/links/<pay_id> -H - "X-Api-Key: {{ user.wallets[0].adminkey }}" + >curl -X DELETE {{ request.base_url + }}lnurlp/api/v1/links/<pay_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html index bd68b629e..a472d549e 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -127,7 +127,13 @@ - + DELETE diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html index 664cda0f1..2c8fe672a 100644 --- a/lnbits/extensions/paywall/templates/paywall/_api_docs.html +++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html @@ -17,8 +17,8 @@ [<paywall_object>, ...]
Curl example
curl -X GET {{ request.base_url }}paywall/api/v1/paywalls -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}paywall/api/v1/paywalls -H + "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -48,11 +48,11 @@ >
Curl example
curl -X POST {{ request.base_url }}paywall/api/v1/paywalls -d '{"url": - <string>, "memo": <string>, "description": <string>, - "amount": <integer>, "remembers": <boolean>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + >curl -X POST {{ request.base_url }}paywall/api/v1/paywalls -d + '{"url": <string>, "memo": <string>, "description": + <string>, "amount": <integer>, "remembers": + <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: + {{ user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html index 644bcdfef..a80fd37ad 100644 --- a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html +++ b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html @@ -17,8 +17,8 @@ [<satsdice_link_object>, ...]
Curl example
curl -X GET {{ request.base_url }}satsdice/api/v1/links -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}satsdice/api/v1/links -H + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -44,8 +44,9 @@ {"lnurl": <string>}
Curl example
curl -X GET {{ request.base_url }}satsdice/api/v1/links/<satsdice_id> -H - "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}satsdice/api/v1/links/<satsdice_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -73,8 +74,8 @@ {"lnurl": <string>}
Curl example
curl -X POST {{ request.base_url }}satsdice/api/v1/links -d '{"title": - <string>, "min_satsdiceable": <integer>, + >curl -X POST {{ request.base_url }}satsdice/api/v1/links -d + '{"title": <string>, "min_satsdiceable": <integer>, "max_satsdiceable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ @@ -109,8 +110,9 @@ {"lnurl": <string>}
Curl example
curl -X PUT {{ request.base_url }}satsdice/api/v1/links/<satsdice_id> -d - '{"title": <string>, "min_satsdiceable": <integer>, + >curl -X PUT {{ request.base_url + }}satsdice/api/v1/links/<satsdice_id> -d '{"title": + <string>, "min_satsdiceable": <integer>, "max_satsdiceable": <integer>, "uses": <integer>, "wait_time": <integer>, "is_unique": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ @@ -137,8 +139,9 @@
Curl example
curl -X DELETE {{ request.base_url }}satsdice/api/v1/links/<satsdice_id> - -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + >curl -X DELETE {{ request.base_url + }}satsdice/api/v1/links/<satsdice_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" @@ -165,8 +168,8 @@
Curl example
curl -X GET {{ request.base_url - }}satsdice/api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + }}satsdice/api/v1/links/<the_hash>/<lnurl_id> -H + "X-Api-Key: {{ user.wallets[0].inkey }}" diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html index 89380de00..77451ae59 100644 --- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -60,12 +60,13 @@ [<charge_object>, ...]
Curl example
curl -X POST {{ request.base_url }}satspay/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: {{user.wallets[0].adminkey }}" + >curl -X POST {{ request.base_url + }}satspay/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: + {{user.wallets[0].adminkey }}" @@ -89,8 +90,9 @@ [<charge_object>, ...]
Curl example
curl -X GET {{ request.base_url }}satspay/api/v1/charge/<charge_id> - -H "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}satspay/api/v1/charge/<charge_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -112,8 +114,8 @@ [<charge_object>, ...]
Curl example
curl -X GET {{ request.base_url }}satspay/api/v1/charges -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}satspay/api/v1/charges -H + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -145,7 +147,8 @@
diff --git a/lnbits/extensions/splitpayments/static/js/index.js b/lnbits/extensions/splitpayments/static/js/index.js index dea469e54..d9750bef1 100644 --- a/lnbits/extensions/splitpayments/static/js/index.js +++ b/lnbits/extensions/splitpayments/static/js/index.js @@ -119,7 +119,7 @@ new Vue({ '/splitpayments/api/v1/targets', this.selectedWallet.adminkey, { - "targets": this.targets + targets: this.targets .filter(isTargetComplete) .map(({wallet, percent, alias}) => ({wallet, percent, alias})) } diff --git a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html index 7a8a6d07e..4cf7190c3 100644 --- a/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html +++ b/lnbits/extensions/splitpayments/templates/splitpayments/_api_docs.html @@ -52,8 +52,8 @@ >
Curl example
curl -X GET {{ request.base_url }}splitpayments/api/v1/targets -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}splitpayments/api/v1/targets -H + "X-Api-Key: {{ user.wallets[0].inkey }}" diff --git a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html index 42788bad6..95ba6e064 100644 --- a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html +++ b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html @@ -4,10 +4,9 @@ Tip Jar: Receive tips with messages!

- Your personal Bitcoin tip page, which supports - lightning and on-chain payments. - Notifications, including a donation message, - can be sent via webhook. + Your personal Bitcoin tip page, which supports lightning and on-chain + payments. Notifications, including a donation message, can be sent via + webhook. Created by, Fitti diff --git a/lnbits/extensions/tipjar/templates/tipjar/index.html b/lnbits/extensions/tipjar/templates/tipjar/index.html index dda49842b..19fca6e47 100644 --- a/lnbits/extensions/tipjar/templates/tipjar/index.html +++ b/lnbits/extensions/tipjar/templates/tipjar/index.html @@ -322,11 +322,7 @@ var self = this LNbits.api - .request( - 'GET', - '/tipjar/api/v1/tips', - this.g.user.wallets[0].inkey - ) + .request('GET', '/tipjar/api/v1/tips', this.g.user.wallets[0].inkey) .then(function (response) { self.tips = response.data.map(function (obj) { return mapTipJar(obj) diff --git a/lnbits/extensions/tpos/templates/tpos/_api_docs.html b/lnbits/extensions/tpos/templates/tpos/_api_docs.html index 42160cce2..8930d9903 100644 --- a/lnbits/extensions/tpos/templates/tpos/_api_docs.html +++ b/lnbits/extensions/tpos/templates/tpos/_api_docs.html @@ -69,8 +69,8 @@

Curl example
curl -X DELETE {{ request.base_url }}tpos/api/v1/tposs/<tpos_id> -H - "X-Api-Key: <admin_key>" + >curl -X DELETE {{ request.base_url + }}tpos/api/v1/tposs/<tpos_id> -H "X-Api-Key: <admin_key>" diff --git a/lnbits/extensions/tpos/templates/tpos/index.html b/lnbits/extensions/tpos/templates/tpos/index.html index af3b0573f..76f330007 100644 --- a/lnbits/extensions/tpos/templates/tpos/index.html +++ b/lnbits/extensions/tpos/templates/tpos/index.html @@ -54,7 +54,8 @@ >
- {{ (col.name == 'tip_options' ? JSON.parse(col.value).join(", ") : col.value) }} + {{ (col.name == 'tip_options' ? JSON.parse(col.value).join(", ") + : col.value) }} parseInt(str))) : JSON.stringify([])), - tip_wallet: this.formDialog.data.tip_wallet || "", + tip_options: this.formDialog.data.tip_options + ? JSON.stringify( + this.formDialog.data.tip_options.map(str => parseInt(str)) + ) + : JSON.stringify([]), + tip_wallet: this.formDialog.data.tip_wallet || '' } var self = this diff --git a/lnbits/extensions/tpos/templates/tpos/tpos.html b/lnbits/extensions/tpos/templates/tpos/tpos.html index e4ea14992..70b84f9b3 100644 --- a/lnbits/extensions/tpos/templates/tpos/tpos.html +++ b/lnbits/extensions/tpos/templates/tpos/tpos.html @@ -1,7 +1,5 @@ -{% extends "public.html" %} -{% block toolbar_title %} -{{ tpos.name }} +{% extends "public.html" %} {% block toolbar_title %} {{ tpos.name }} -{% endblock %} -{% block footer %}{% endblock %} {% block page_container %} +{% endblock %} {% block footer %}{% endblock %} {% block page_container %} @@ -180,27 +177,24 @@ - +
- Would you like to leave a tip? + Would you like to leave a tip?
- {% raw %}{{ tip }}{% endraw %}% + {% raw %}{{ tip }}{% endraw %}%

No, thanks

@@ -265,7 +259,7 @@ } .keypad .btn-confirm { - grid-area: 1 / 4 / 5 / 4; + grid-area: 1 / 4 / 5 / 4; } {% endblock %} {% block scripts %} @@ -282,7 +276,7 @@ tip_options: JSON.parse('{{ tpos.tip_options }}'), exchangeRate: null, stack: [], - tipAmount: 0.00, + tipAmount: 0.0, invoiceDialog: { show: false, data: null, @@ -290,7 +284,7 @@ paymentChecker: null }, tipDialog: { - show: false, + show: false }, urlDialog: { show: false @@ -324,7 +318,7 @@ methods: { closeInvoiceDialog: function () { this.stack = [] - this.tipAmount = 0.00 + this.tipAmount = 0.0 var dialog = this.invoiceDialog setTimeout(function () { clearInterval(dialog.paymentChecker) @@ -334,8 +328,10 @@ processTipSelection: function (selectedTipOption) { this.tipDialog.show = false - if(selectedTipOption) { - const tipAmount = parseFloat(parseFloat((selectedTipOption / 100) * this.amount)) + if (selectedTipOption) { + const tipAmount = parseFloat( + parseFloat((selectedTipOption / 100) * this.amount) + ) const subtotal = parseFloat(this.amount) const grandTotal = parseFloat((tipAmount + subtotal).toFixed(2)) const totalString = grandTotal.toFixed(2).toString() @@ -344,7 +340,7 @@ for (var i = 0; i < totalString.length; i++) { const char = totalString[i] - if(char !== ".") { + if (char !== '.') { this.stack.push(char) } } @@ -354,14 +350,14 @@ this.showInvoice() }, - submitForm: function() { - if(this.tip_options.length) { + submitForm: function () { + if (this.tip_options.length) { this.showTipModal() } else { this.showInvoice() } }, - showTipModal: function() { + showTipModal: function () { this.tipDialog.show = true }, showInvoice: function () { @@ -372,7 +368,7 @@ .post('/tpos/api/v1/tposs/' + this.tposId + '/invoices', null, { params: { amount: this.sat, - tipAmount: this.tipAmountSat, + tipAmount: this.tipAmountSat } }) .then(function (response) { diff --git a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html index bff663833..b421186ad 100644 --- a/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html +++ b/lnbits/extensions/watchonly/templates/watchonly/_api_docs.html @@ -37,8 +37,8 @@ [<wallets_object>, ...]
Curl example
curl -X GET {{ request.base_url }}watchonly/api/v1/wallet -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}watchonly/api/v1/wallet -H + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -66,8 +66,9 @@ [<wallet_object>, ...]
Curl example
curl -X GET {{ request.base_url }}watchonly/api/v1/wallet/<wallet_id> - -H "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}watchonly/api/v1/wallet/<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -89,9 +90,10 @@ [<wallet_object>, ...]
Curl example
curl -X POST {{ request.base_url }}watchonly/api/v1/wallet -d '{"title": - <string>, "masterpub": <string>}' -H "Content-type: - application/json" -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + >curl -X POST {{ request.base_url }}watchonly/api/v1/wallet -d + '{"title": <string>, "masterpub": <string>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" @@ -173,8 +175,9 @@ [<address_object>, ...]
Curl example
curl -X GET {{ request.base_url }}watchonly/api/v1/address/<wallet_id> - -H "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}watchonly/api/v1/address/<wallet_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -202,8 +205,8 @@ [<mempool_object>, ...]
Curl example
curl -X GET {{ request.base_url }}watchonly/api/v1/mempool -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + >curl -X GET {{ request.base_url }}watchonly/api/v1/mempool -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" @@ -233,9 +236,9 @@ [<mempool_object>, ...]
Curl example
curl -X PUT {{ request.base_url }}watchonly/api/v1/mempool -d '{"endpoint": - <string>}' -H "Content-type: application/json" -H "X-Api-Key: - {{ user.wallets[0].adminkey }}" + >curl -X PUT {{ request.base_url }}watchonly/api/v1/mempool -d + '{"endpoint": <string>}' -H "Content-type: application/json" + -H "X-Api-Key: {{ user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html index 76068fcb2..095aad3ad 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html +++ b/lnbits/extensions/withdraw/templates/withdraw/_api_docs.html @@ -22,8 +22,8 @@ [<withdraw_link_object>, ...]
Curl example
curl -X GET {{ request.base_url }}withdraw/api/v1/links -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url }}withdraw/api/v1/links -H + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -49,8 +49,9 @@ {"lnurl": <string>}
Curl example
curl -X GET {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> -H - "X-Api-Key: {{ user.wallets[0].inkey }}" + >curl -X GET {{ request.base_url + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ + user.wallets[0].inkey }}" @@ -79,12 +80,12 @@ {"lnurl": <string>}
Curl example
curl -X POST {{ request.base_url }}withdraw/api/v1/links -d '{"title": - <string>, "min_withdrawable": <integer>, + >curl -X POST {{ request.base_url }}withdraw/api/v1/links -d + '{"title": <string>, "min_withdrawable": <integer>, "max_withdrawable": <integer>, "uses": <integer>, - "wait_time": <integer>, "is_unique": <boolean>, "webhook_url": <string>}' -H - "Content-type: application/json" -H "X-Api-Key: {{ - user.wallets[0].adminkey }}" + "wait_time": <integer>, "is_unique": <boolean>, + "webhook_url": <string>}' -H "Content-type: application/json" -H + "X-Api-Key: {{ user.wallets[0].adminkey }}" @@ -115,8 +116,9 @@ {"lnurl": <string>}
Curl example
curl -X PUT {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> -d - '{"title": <string>, "min_withdrawable": <integer>, + >curl -X PUT {{ request.base_url + }}withdraw/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: {{ @@ -143,8 +145,9 @@
Curl example
curl -X DELETE {{ request.base_url }}withdraw/api/v1/links/<withdraw_id> - -H "X-Api-Key: {{ user.wallets[0].adminkey }}" + >curl -X DELETE {{ request.base_url + }}withdraw/api/v1/links/<withdraw_id> -H "X-Api-Key: {{ + user.wallets[0].adminkey }}" @@ -171,8 +174,8 @@
Curl example
curl -X GET {{ request.base_url - }}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{ - user.wallets[0].inkey }}" + }}withdraw/api/v1/links/<the_hash>/<lnurl_id> -H + "X-Api-Key: {{ user.wallets[0].inkey }}" diff --git a/lnbits/extensions/withdraw/templates/withdraw/csv.html b/lnbits/extensions/withdraw/templates/withdraw/csv.html index d8f8c4d0d..62902905d 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/csv.html +++ b/lnbits/extensions/withdraw/templates/withdraw/csv.html @@ -1,10 +1,12 @@ -{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor %} {% endblock %} {% block scripts %} +{% extends "print.html" %} {% block page %} {% for page in link %} {% for threes +in page %} {% for one in threes %} {{one}}, {% endfor %} {% endfor %} {% endfor +%} {% endblock %} {% block scripts %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html index f6ea220df..6d3ab374a 100644 --- a/lnbits/extensions/withdraw/templates/withdraw/index.html +++ b/lnbits/extensions/withdraw/templates/withdraw/index.html @@ -1,28 +1,40 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }} +{% 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) - - +
+ + + Quick vouchers + Advanced withdraw link(s) + + - - -
-
-
Withdraw links
-
-
- Export to CSV -
-
- - {% raw %} -

+ Connect your LNbits instance to a Discord Bot leveraging LNbits as a community based lightning node.
+ + Created by, Chris Lennon
+ + Based on User Manager, by Ben Arc +