From 7be1859c94e3941aff4590280e81ae54ac0fd054 Mon Sep 17 00:00:00 2001 From: benarc Date: Fri, 22 Oct 2021 09:37:15 +0100 Subject: [PATCH 01/27] print --- lnbits/extensions/lnurlpos/lnurl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lnbits/extensions/lnurlpos/lnurl.py b/lnbits/extensions/lnurlpos/lnurl.py index 806f6dda..2d355ca1 100644 --- a/lnbits/extensions/lnurlpos/lnurl.py +++ b/lnbits/extensions/lnurlpos/lnurl.py @@ -64,6 +64,7 @@ async def lnurl_response( pin=decryptedPin, payhash="payment_hash", ) + print(price_msat) if not lnurlpospayment: raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="Could not create payment" From a6de60aab7f3a18a8821e906dddb3761afa3a57b Mon Sep 17 00:00:00 2001 From: benarc Date: Fri, 22 Oct 2021 09:41:06 +0100 Subject: [PATCH 02/27] Fixed exchagerates. I think --- lnbits/utils/exchange_rates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py index 14e912fc..7e2e782c 100644 --- a/lnbits/utils/exchange_rates.py +++ b/lnbits/utils/exchange_rates.py @@ -244,9 +244,9 @@ async def btc_price(currency: str) -> float: r.raise_for_status() data = r.json() rate = float(provider.getter(data, replacements)) - await send_channel.send(rate) + await send_channel.put(rate) except Exception: - await send_channel.send(None) + await send_channel.put(None) # asyncio.create_task(controller, nursery) for key, provider in exchange_rate_providers.items(): From e532ce19718044119162fd4f9f689b46f250bb00 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 22 Oct 2021 11:00:15 +0100 Subject: [PATCH 03/27] dirty fix for exchange rates --- lnbits/utils/exchange_rates.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py index 7e2e782c..0e9207b3 100644 --- a/lnbits/utils/exchange_rates.py +++ b/lnbits/utils/exchange_rates.py @@ -1,7 +1,8 @@ import asyncio -import httpx from typing import Callable, NamedTuple +import httpx + currencies = { "AED": "United Arab Emirates Dirham", "AFN": "Afghan Afghani", @@ -244,14 +245,15 @@ async def btc_price(currency: str) -> float: r.raise_for_status() data = r.json() rate = float(provider.getter(data, replacements)) + rates.append(rate) await send_channel.put(rate) except Exception: await send_channel.put(None) # asyncio.create_task(controller, nursery) for key, provider in exchange_rate_providers.items(): + await fetch_price(key, provider) asyncio.create_task(fetch_price(key, provider)) - if not rates: return 9999999999 From 7550c968e76fb87d3e24f5b676c64817e9983983 Mon Sep 17 00:00:00 2001 From: Stefan Stammberger Date: Sat, 23 Oct 2021 16:41:53 +0200 Subject: [PATCH 04/27] fix: fetch btc price function --- lnbits/utils/exchange_rates.py | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py index 0e9207b3..f6a7158f 100644 --- a/lnbits/utils/exchange_rates.py +++ b/lnbits/utils/exchange_rates.py @@ -220,42 +220,30 @@ async def btc_price(currency: str) -> float: "to": currency.lower(), } rates = [] - send_channel = asyncio.Queue(0) - async def controller(nursery): - failures = 0 - while True: - rate = await send_channel.get() - if rate: - rates.append(rate) - else: - failures += 1 - if len(rates) >= 2 or len(rates) == 1 and failures >= 2: - nursery.cancel_scope.cancel() - break - if failures == len(exchange_rate_providers): - nursery.cancel_scope.cancel() - break - - async def fetch_price(key: str, provider: Provider): + async def fetch_price(provider: Provider): + url = provider.api_url.format(**replacements) try: - url = provider.api_url.format(**replacements) async with httpx.AsyncClient() as client: r = await client.get(url, timeout=0.5) r.raise_for_status() data = r.json() rate = float(provider.getter(data, replacements)) rates.append(rate) - await send_channel.put(rate) except Exception: - await send_channel.put(None) + pass + except httpx.ConnectError: + pass - # asyncio.create_task(controller, nursery) + tasks = [] for key, provider in exchange_rate_providers.items(): - await fetch_price(key, provider) - asyncio.create_task(fetch_price(key, provider)) + tasks.append(asyncio.create_task(fetch_price(key, provider))) + await asyncio.gather(*tasks) + if not rates: return 9999999999 + elif rates == 1: + print("Warning could only fetch one Bitcoin price.") return sum([rate for rate in rates]) / len(rates) From d1b628375a119540cd4222718cdd6a75866f0911 Mon Sep 17 00:00:00 2001 From: Stefan Stammberger Date: Sun, 24 Oct 2021 10:14:16 +0200 Subject: [PATCH 05/27] fix: revert some changes from last commit Reintroduced the controller which canceles remaining requests once two results are received. --- lnbits/utils/exchange_rates.py | 43 ++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py index f6a7158f..685b4bf6 100644 --- a/lnbits/utils/exchange_rates.py +++ b/lnbits/utils/exchange_rates.py @@ -220,6 +220,26 @@ async def btc_price(currency: str) -> float: "to": currency.lower(), } rates = [] + tasks = [] + + send_channel = asyncio.Queue() + + async def controller(): + failures = 0 + while True: + rate = await send_channel.get() + if rate: + rates.append(rate) + else: + failures += 1 + + if len(rates) >= 2 or len(rates) == 1 and failures >= 2: + for t in tasks: t.cancel() + break + if failures == len(exchange_rate_providers): + for t in tasks: t.cancel() + break + async def fetch_price(provider: Provider): url = provider.api_url.format(**replacements) @@ -229,20 +249,23 @@ async def btc_price(currency: str) -> float: r.raise_for_status() data = r.json() rate = float(provider.getter(data, replacements)) - rates.append(rate) - except Exception: - pass - except httpx.ConnectError: - pass + await send_channel.put(rate) + except (httpx.ConnectTimeout, httpx.ConnectError, httpx.ReadTimeout): + await send_channel.put(None) - tasks = [] - for key, provider in exchange_rate_providers.items(): - tasks.append(asyncio.create_task(fetch_price(key, provider))) - await asyncio.gather(*tasks) + + asyncio.create_task(controller()) + for _, provider in exchange_rate_providers.items(): + tasks.append(asyncio.create_task(fetch_price(provider))) + + try: + await asyncio.gather(*tasks) + except asyncio.CancelledError: + pass if not rates: return 9999999999 - elif rates == 1: + elif len(rates) == 1: print("Warning could only fetch one Bitcoin price.") return sum([rate for rate in rates]) / len(rates) From a626155c33193b88477b6616113135a21a7ebaaa Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 25 Oct 2021 11:54:58 +0100 Subject: [PATCH 06/27] satsdice still errors on withdraw --- lnbits/extensions/satsdice/crud.py | 15 ++-- lnbits/extensions/satsdice/lnurl.py | 29 +++----- .../satsdice/templates/satsdice/index.html | 10 +-- lnbits/extensions/satsdice/views.py | 71 ++++++++++--------- lnbits/extensions/satsdice/views_api.py | 11 ++- lnbits/extensions/withdraw/lnurl.py | 10 +-- 6 files changed, 68 insertions(+), 78 deletions(-) diff --git a/lnbits/extensions/satsdice/crud.py b/lnbits/extensions/satsdice/crud.py index f6833487..c0164bc5 100644 --- a/lnbits/extensions/satsdice/crud.py +++ b/lnbits/extensions/satsdice/crud.py @@ -1,21 +1,22 @@ from datetime import datetime from typing import List, Optional, Union + from lnbits.helpers import urlsafe_short_hash -from typing import List, Optional + from . import db from .models import ( - satsdiceWithdraw, - HashCheck, - satsdiceLink, - satsdicePayment, CreateSatsDiceLink, CreateSatsDicePayment, CreateSatsDiceWithdraw, + HashCheck, + satsdiceLink, + satsdicePayment, + satsdiceWithdraw, ) -from lnbits.helpers import urlsafe_short_hash async def create_satsdice_pay( + wallet_id: str, data: CreateSatsDiceLink, ) -> satsdiceLink: satsdice_id = urlsafe_short_hash() @@ -40,7 +41,7 @@ async def create_satsdice_pay( """, ( satsdice_id, - data.wallet_id, + wallet_id, data.title, data.base_url, data.min_bet, diff --git a/lnbits/extensions/satsdice/lnurl.py b/lnbits/extensions/satsdice/lnurl.py index ef09fbbd..b5b48370 100644 --- a/lnbits/extensions/satsdice/lnurl.py +++ b/lnbits/extensions/satsdice/lnurl.py @@ -1,33 +1,24 @@ -import shortuuid # type: ignore import hashlib -import math import json +import math from http import HTTPStatus -from datetime import datetime -from lnbits.core.services import pay_invoice, create_invoice -from http import HTTPStatus -from starlette.exceptions import HTTPException -from starlette.responses import HTMLResponse, JSONResponse # type: ignore -from lnbits.utils.exchange_rates import get_fiat_rate_satoshis -from fastapi import FastAPI, Request -from fastapi.params import Depends -from typing import Optional + +from fastapi import Request from fastapi.param_functions import Query +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse # type: ignore + +from lnbits.core.services import create_invoice, pay_invoice + from . import satsdice_ext from .crud import ( + create_satsdice_payment, + get_satsdice_pay, get_satsdice_withdraw_by_hash, update_satsdice_withdraw, - get_satsdice_pay, - create_satsdice_payment, -) -from lnurl import ( - LnurlPayResponse, - LnurlPayActionResponse, - LnurlErrorResponse, ) from .models import CreateSatsDicePayment - ##############LNURLP STUFF diff --git a/lnbits/extensions/satsdice/templates/satsdice/index.html b/lnbits/extensions/satsdice/templates/satsdice/index.html index a5ec243d..f383f0df 100644 --- a/lnbits/extensions/satsdice/templates/satsdice/index.html +++ b/lnbits/extensions/satsdice/templates/satsdice/index.html @@ -342,7 +342,7 @@ LNbits.api .request( 'GET', - '/satsdice/api/v1/links?all_wallets', + '/satsdice/api/v1/links?all_wallets=true', this.g.user.wallets[0].inkey ) .then(response => { @@ -446,7 +446,7 @@ key === 'success_url') && (value === null || value === '') ) - + LNbits.api .request( 'PUT', @@ -516,9 +516,9 @@ if (this.g.user.wallets.length) { var getPayLinks = this.getPayLinks getPayLinks() - this.checker = setInterval(() => { - getPayLinks() - }, 20000) + // this.checker = setInterval(() => { + // getPayLinks() + // }, 20000) } } }) diff --git a/lnbits/extensions/satsdice/views.py b/lnbits/extensions/satsdice/views.py index 9e6ef259..bbe19fe0 100644 --- a/lnbits/extensions/satsdice/views.py +++ b/lnbits/extensions/satsdice/views.py @@ -1,48 +1,53 @@ +import random from datetime import datetime from http import HTTPStatus -from lnbits.decorators import check_user_exists, WalletTypeInfo, get_key_type -from . import satsdice_ext, satsdice_renderer -from .crud import ( - get_satsdice_pay, - update_satsdice_payment, - get_satsdice_payment, - create_satsdice_withdraw, - get_satsdice_withdraw, -) -from lnbits.core.crud import ( - get_payments, - get_standalone_payment, - delete_expired_invoices, - get_balance_checks, -) -from lnbits.core.views.api import api_payment -from lnbits.core.services import check_invoice_status + from fastapi import FastAPI, Request +from fastapi.param_functions import Query from fastapi.params import Depends from fastapi.templating import Jinja2Templates from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse -from lnbits.core.models import User, Payment -from fastapi.params import Depends -from fastapi.param_functions import Query -import random -from .models import CreateSatsDiceWithdraw + +from lnbits.core.crud import ( + delete_expired_invoices, + get_balance_checks, + get_payments, + get_standalone_payment, +) +from lnbits.core.models import Payment, User +from lnbits.core.services import check_invoice_status +from lnbits.core.views.api import api_payment +from lnbits.decorators import WalletTypeInfo, check_user_exists, get_key_type + +from . import satsdice_ext, satsdice_renderer +from .crud import ( + create_satsdice_withdraw, + get_satsdice_pay, + get_satsdice_payment, + get_satsdice_withdraw, + update_satsdice_payment, +) +from .models import CreateSatsDiceWithdraw, satsdiceLink templates = Jinja2Templates(directory="templates") -@satsdice_ext.get("/") +@satsdice_ext.get("/", response_class=HTMLResponse) async def index(request: Request, user: User = Depends(check_user_exists)): return satsdice_renderer().TemplateResponse( "satsdice/index.html", {"request": request, "user": user.dict()} ) -@satsdice_ext.get("/{link_id}") +@satsdice_ext.get("/{link_id}", response_class=HTMLResponse) async def display(request: Request, link_id: str = Query(None)): - link = await get_satsdice_pay(link_id) or abort( - HTTPStatus.NOT_FOUND, "satsdice link does not exist." - ) + link = await get_satsdice_pay(link_id) + if not link: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="satsdice link does not exist." + ) + return satsdice_renderer().TemplateResponse( "satsdice/display.html", { @@ -55,13 +60,15 @@ async def display(request: Request, link_id: str = Query(None)): ) -@satsdice_ext.get("/win/{link_id}/{payment_hash}", name="satsdice.displaywin") +@satsdice_ext.get("/win/{link_id}/{payment_hash}", name="satsdice.displaywin", response_class=HTMLResponse) async def displaywin( request: Request, link_id: str = Query(None), payment_hash: str = Query(None) ): - satsdicelink = await get_satsdice_pay(link_id) or abort( - HTTPStatus.NOT_FOUND, "satsdice link does not exist." - ) + satsdicelink = await get_satsdice_pay(link_id) + if not satsdiceLink: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="satsdice link does not exist." + ) withdrawLink = await get_satsdice_withdraw(payment_hash) if withdrawLink: @@ -118,7 +125,7 @@ async def displaywin( ) -@satsdice_ext.get("/img/{link_id}") +@satsdice_ext.get("/img/{link_id}", response_class=HTMLResponse) async def img(link_id): link = await get_satsdice_pay(link_id) or abort( HTTPStatus.NOT_FOUND, "satsdice link does not exist." diff --git a/lnbits/extensions/satsdice/views_api.py b/lnbits/extensions/satsdice/views_api.py index 91ce62df..b935ba88 100644 --- a/lnbits/extensions/satsdice/views_api.py +++ b/lnbits/extensions/satsdice/views_api.py @@ -31,7 +31,7 @@ from .models import CreateSatsDiceLink, CreateSatsDiceWithdraws async def api_links( request: Request, wallet: WalletTypeInfo = Depends(get_key_type), - all_wallets: str = Query(None), + all_wallets: bool = Query(False), ): wallet_ids = [wallet.wallet.id] @@ -81,7 +81,6 @@ async def api_link_create_or_update( raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request") if link_id: link = await get_satsdice_pay(link_id) - if not link: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Satsdice does not exist" @@ -92,11 +91,11 @@ async def api_link_create_or_update( status_code=HTTPStatus.FORBIDDEN, detail="Come on, seriously, this isn't your satsdice!", ) - data.link_id = link_id - link = await update_satsdice_pay(data) - else: + data.wallet_id = wallet.wallet.id - link = await create_satsdice_pay(data) + link = await update_satsdice_pay(link_id, **data.dict()) + else: + link = await create_satsdice_pay(wallet_id=wallet.wallet.id, data=data) return {**link.dict(), **{"lnurl": link.lnurl}} diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index af3ecff4..f4728819 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -59,24 +59,16 @@ async def api_lnurl_callback( raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." ) - # return ( - # {"status": "ERROR", "reason": "LNURL-withdraw not found."}, - # HTTPStatus.OK, - # ) + if link.is_spent: raise HTTPException( # WHAT STATUS_CODE TO USE?? detail="Withdraw is spent." ) - # return ( - # {"status": "ERROR", "reason": "Withdraw is spent."}, - # HTTPStatus.OK, - # ) if link.k1 != k1: raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request.") - # return {"status": "ERROR", "reason": "Bad request."}, HTTPStatus.OK if now < link.open_time: return {"status": "ERROR", "reason": f"Wait {link.open_time - now} seconds."} From 11499041be923ff6aa68a2c15189674306838cce Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 25 Oct 2021 12:11:58 +0100 Subject: [PATCH 07/27] fix lnurlp update link --- lnbits/extensions/lnurlp/views_api.py | 32 +-------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py index 94e15e3a..d01cf1b1 100644 --- a/lnbits/extensions/lnurlp/views_api.py +++ b/lnbits/extensions/lnurlp/views_api.py @@ -27,7 +27,6 @@ async def api_list_currencies_available(): @lnurlp_ext.get("/api/v1/links", status_code=HTTPStatus.OK) -# @api_check_wallet_key("invoice") async def api_links( req: Request, wallet: WalletTypeInfo = Depends(get_key_type), @@ -43,26 +42,15 @@ async def api_links( {**link.dict(), "lnurl": link.lnurl(req)} for link in await get_pay_links(wallet_ids) ] - # return [ - # {**link.dict(), "lnurl": link.lnurl} - # for link in await get_pay_links(wallet_ids) - # ] except LnurlInvalidUrl: raise HTTPException( status_code=HTTPStatus.UPGRADE_REQUIRED, detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.", ) - # return ( - # { - # "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor." - # }, - # HTTPStatus.UPGRADE_REQUIRED, - # ) @lnurlp_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) -# @api_check_wallet_key("invoice") async def api_link_retrieve( r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type) ): @@ -72,20 +60,17 @@ async def api_link_retrieve( raise HTTPException( detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND ) - # return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND if link.wallet != wallet.wallet.id: raise HTTPException( detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN ) - # return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN return {**link._asdict(), **{"lnurl": link.lnurl(r)}} @lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED) @lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) -# @api_check_wallet_key("invoice") async def api_link_create_or_update( data: CreatePayLinkData, link_id=None, @@ -95,7 +80,6 @@ async def api_link_create_or_update( raise HTTPException( detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST ) - # return {"message": "Min is greater than max."}, HTTPStatus.BAD_REQUEST if data.currency == None and ( round(data.min) != data.min or round(data.max) != data.max @@ -103,17 +87,12 @@ async def api_link_create_or_update( raise HTTPException( detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST ) - # return {"message": "Must use full satoshis."}, HTTPStatus.BAD_REQUEST if "success_url" in data and data.success_url[:8] != "https://": raise HTTPException( detail="Success URL must be secure https://...", status_code=HTTPStatus.BAD_REQUEST, ) - # return ( - # {"message": "Success URL must be secure https://..."}, - # HTTPStatus.BAD_REQUEST, - # ) if link_id: link = await get_pay_link(link_id) @@ -122,18 +101,13 @@ async def api_link_create_or_update( raise HTTPException( detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND ) - # return ( - # {"message": "Pay link does not exist."}, - # HTTPStatus.NOT_FOUND, - # ) if link.wallet != wallet.wallet.id: raise HTTPException( detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN ) - # return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN - link = await update_pay_link(data, link_id=link_id) + link = await update_pay_link(**data.dict(), link_id=link_id) else: link = await create_pay_link(data, wallet_id=wallet.wallet.id) print("LINK", link) @@ -141,7 +115,6 @@ async def api_link_create_or_update( @lnurlp_ext.delete("/api/v1/links/{link_id}") -# @api_check_wallet_key("invoice") async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)): link = await get_pay_link(link_id) @@ -149,17 +122,14 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type raise HTTPException( detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND ) - # return {"message": "Pay link does not exist."}, HTTPStatus.NOT_FOUND if link.wallet != wallet.wallet.id: raise HTTPException( detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN ) - # return {"message": "Not your pay link."}, HTTPStatus.FORBIDDEN await delete_pay_link(link_id) raise HTTPException(status_code=HTTPStatus.NO_CONTENT) - # return "", HTTPStatus.NO_CONTENT @lnurlp_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK) From a9e9c67a6fd641d8f1f46d8fbc1317ed8f648465 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 25 Oct 2021 12:19:02 +0100 Subject: [PATCH 08/27] fix vue console error getservices --- lnbits/extensions/tipjar/templates/tipjar/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/tipjar/templates/tipjar/index.html b/lnbits/extensions/tipjar/templates/tipjar/index.html index 7c58a74f..dda49842 100644 --- a/lnbits/extensions/tipjar/templates/tipjar/index.html +++ b/lnbits/extensions/tipjar/templates/tipjar/index.html @@ -439,7 +439,7 @@ this.getWalletLinks() this.getTipJars() this.getTips() - this.getServices() + // this.getServices() } } }) From c4a63e0f2e754a01c7a3dc19cf76cafac858090e Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 25 Oct 2021 12:42:24 +0100 Subject: [PATCH 09/27] fix create aditional wallet --- lnbits/extensions/usermanager/crud.py | 9 +++++++-- lnbits/extensions/usermanager/models.py | 5 +++++ lnbits/extensions/usermanager/views_api.py | 10 ++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lnbits/extensions/usermanager/crud.py b/lnbits/extensions/usermanager/crud.py index 4272d726..1ce66d4f 100644 --- a/lnbits/extensions/usermanager/crud.py +++ b/lnbits/extensions/usermanager/crud.py @@ -1,7 +1,12 @@ from typing import List, Optional -from lnbits.core.crud import (create_account, create_wallet, delete_wallet, - get_payments, get_user) +from lnbits.core.crud import ( + create_account, + create_wallet, + delete_wallet, + get_payments, + get_user, +) from lnbits.core.models import Payment from . import db diff --git a/lnbits/extensions/usermanager/models.py b/lnbits/extensions/usermanager/models.py index a0845233..1dc7b85a 100644 --- a/lnbits/extensions/usermanager/models.py +++ b/lnbits/extensions/usermanager/models.py @@ -11,6 +11,11 @@ class CreateUserData(BaseModel): email: str = Query("") password: str = Query("") +class CreateUserWallet(BaseModel): + user_id: str = Query(...) + wallet_name: str = Query(...) + admin_id: str = Query(...) + class Users(BaseModel): id: str diff --git a/lnbits/extensions/usermanager/views_api.py b/lnbits/extensions/usermanager/views_api.py index 7bdee8fc..d4808a6b 100644 --- a/lnbits/extensions/usermanager/views_api.py +++ b/lnbits/extensions/usermanager/views_api.py @@ -21,7 +21,7 @@ from .crud import ( get_usermanager_wallet_transactions, get_usermanager_wallets, ) -from .models import CreateUserData +from .models import CreateUserData, CreateUserWallet ### Users @@ -93,12 +93,10 @@ async def api_usermanager_activate_extension( @usermanager_ext.post("/api/v1/wallets") async def api_usermanager_wallets_create( - wallet: WalletTypeInfo = Depends(get_key_type), - user_id: str = Query(...), - wallet_name: str = Query(...), - admin_id: str = Query(...), + data: CreateUserWallet, + wallet: WalletTypeInfo = Depends(get_key_type) ): - user = await create_usermanager_wallet(user_id, wallet_name, admin_id) + user = await create_usermanager_wallet(user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id) return user.dict() From fa1f6e50388db7a83fbb98a2da2caf42c5a0472c Mon Sep 17 00:00:00 2001 From: Stefan Stammberger Date: Mon, 25 Oct 2021 18:57:58 +0200 Subject: [PATCH 10/27] fix: crash when a currency pair is unavailable --- lnbits/utils/exchange_rates.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lnbits/utils/exchange_rates.py b/lnbits/utils/exchange_rates.py index 685b4bf6..2048fb4d 100644 --- a/lnbits/utils/exchange_rates.py +++ b/lnbits/utils/exchange_rates.py @@ -250,7 +250,13 @@ async def btc_price(currency: str) -> float: data = r.json() rate = float(provider.getter(data, replacements)) await send_channel.put(rate) - except (httpx.ConnectTimeout, httpx.ConnectError, httpx.ReadTimeout): + except ( + TypeError, # CoinMate returns HTTPStatus 200 but no data when a currency pair is not found + httpx.ConnectTimeout, + httpx.ConnectError, + httpx.ReadTimeout, + httpx.HTTPStatusError, # Some providers throw a 404 when a currency pair is not found + ): await send_channel.put(None) From e58a6923fcdfc3fe06ee21a23b98ecae80e3a69e Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 25 Oct 2021 18:25:57 +0100 Subject: [PATCH 11/27] proposal to allow decimal valued items in fiat --- lnbits/extensions/offlineshop/lnurl.py | 24 +++++++++---------- lnbits/extensions/offlineshop/migrations.py | 3 +++ .../extensions/offlineshop/static/js/index.js | 5 +++- lnbits/extensions/offlineshop/views.py | 10 ++++---- lnbits/extensions/offlineshop/views_api.py | 13 ++++++---- 5 files changed, 33 insertions(+), 22 deletions(-) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index ea576f1d..22e470da 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -1,20 +1,19 @@ import hashlib -from lnbits.extensions.offlineshop.models import Item -from fastapi.params import Query -from starlette.requests import Request -from lnbits.helpers import url_for -from lnurl import ( - LnurlPayResponse, - LnurlPayActionResponse, +from fastapi.params import Query +from lnurl import ( # type: ignore LnurlErrorResponse, -) # type: ignore + LnurlPayActionResponse, + LnurlPayResponse, +) +from starlette.requests import Request from lnbits.core.services import create_invoice +from lnbits.extensions.offlineshop.models import Item from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from . import offlineshop_ext -from .crud import get_shop, get_item +from .crud import get_item, get_shop @offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response") @@ -27,7 +26,7 @@ async def lnurl_response(req: Request, item_id: int = Query(...)): return {"status": "ERROR", "reason": "Item disabled."} price_msat = ( - await fiat_amount_as_satoshis(item.price, item.unit) + await fiat_amount_as_satoshis(item.price / 1000, item.unit) if item.unit != "sat" else item.price ) * 1000 @@ -47,12 +46,12 @@ async def lnurl_callback(request: Request, item_id: int): item = await get_item(item_id) # type: Item if not item: return {"status": "ERROR", "reason": "Couldn't find item."} - + if item.unit == "sat": min = item.price * 1000 max = item.price * 1000 else: - price = await fiat_amount_as_satoshis(item.price, item.unit) + price = await fiat_amount_as_satoshis(item.price / 1000, item.unit) # allow some fluctuation (the fiat price may have changed between the calls) min = price * 995 max = price * 1010 @@ -68,7 +67,6 @@ async def lnurl_callback(request: Request, item_id: int): ).dict() shop = await get_shop(item.shop) - try: payment_hash, payment_request = await create_invoice( wallet_id=shop.wallet, diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py index f7c2dfec..2d86a644 100644 --- a/lnbits/extensions/offlineshop/migrations.py +++ b/lnbits/extensions/offlineshop/migrations.py @@ -27,3 +27,6 @@ async def m001_initial(db): ); """ ) + +async def m002_store_fiat_in_cents(db): + await db.execute("UPDATE offlineshop.items SET price = (price * 1000) WHERE unit NOT LIKE 'sat'") diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index 00e93241..7be1b26f 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -31,7 +31,7 @@ new Vue({ computed: { printItems() { return this.offlineshop.items.filter(({enabled}) => enabled) - } + }, }, methods: { openNewDialog() { @@ -119,6 +119,9 @@ new Vue({ }, async sendItem() { let {id, name, image, description, price, unit} = this.itemDialog.data + //convert fiat price to milli (int) + price = unit == 'sat' ? price : price * 1000 + const data = { name, description, diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py index 748d2024..fb771166 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -3,17 +3,17 @@ from datetime import datetime from http import HTTPStatus from typing import List +from fastapi import HTTPException, Request from fastapi.params import Depends, Query from starlette.responses import HTMLResponse -from lnbits.decorators import check_user_exists -from lnbits.core.models import Payment, User from lnbits.core.crud import get_standalone_payment +from lnbits.core.models import Payment, User +from lnbits.decorators import check_user_exists from . import offlineshop_ext, offlineshop_renderer -from .models import Item from .crud import get_item, get_shop -from fastapi import Request, HTTPException +from .models import Item @offlineshop_ext.get("/", response_class=HTMLResponse) @@ -29,6 +29,8 @@ async def print_qr_codes(request: Request, items: List[int] = None): for item_id in request.query_params.get("items").split(","): item = await get_item(item_id) # type: Item if item: + if item.unit != 'sat': + item.price = (item.price / 1000) items.append( { "lnurl": item.lnurl(request), diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index 90652651..afb7d7ee 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -27,7 +27,7 @@ from .models import ShopCounter @offlineshop_ext.get("/api/v1/currencies") async def api_list_currencies_available(): - return json.dumps(list(currencies.keys())) + return list(currencies.keys()) @offlineshop_ext.get("/api/v1/offlineshop") @@ -37,7 +37,12 @@ async def api_shop_from_wallet( ): shop = await get_or_create_shop_by_wallet(wallet.wallet.id) items = await get_items(shop.id) - + + #revert millicents to unit + for item in items: + if item.unit != 'sat': + item.price = item.price / 1000 + try: return { **shop.dict(), @@ -60,11 +65,11 @@ class CreateItemsData(BaseModel): @offlineshop_ext.post("/api/v1/offlineshop/items") @offlineshop_ext.put("/api/v1/offlineshop/items/{item_id}") -# @api_check_wallet_key("invoice") async def api_add_or_update_item( data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type) ): - shop = await get_or_create_shop_by_wallet(wallet.wallet.id) + shop = await get_or_create_shop_by_wallet(wallet.wallet.id) + if item_id == None: await add_item( shop.id, data.name, data.description, data.image, data.price, data.unit From 0125c07c6bd197f07a246214f6f600f636dc211b Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 25 Oct 2021 18:25:57 +0100 Subject: [PATCH 12/27] Revert "proposal to allow decimal valued items in fiat" This reverts commit e58a6923fcdfc3fe06ee21a23b98ecae80e3a69e. --- lnbits/extensions/offlineshop/lnurl.py | 24 ++++++++++--------- lnbits/extensions/offlineshop/migrations.py | 3 --- .../extensions/offlineshop/static/js/index.js | 5 +--- lnbits/extensions/offlineshop/views.py | 10 ++++---- lnbits/extensions/offlineshop/views_api.py | 13 ++++------ 5 files changed, 22 insertions(+), 33 deletions(-) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index 22e470da..ea576f1d 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -1,19 +1,20 @@ import hashlib - +from lnbits.extensions.offlineshop.models import Item from fastapi.params import Query -from lnurl import ( # type: ignore - LnurlErrorResponse, - LnurlPayActionResponse, - LnurlPayResponse, -) + from starlette.requests import Request +from lnbits.helpers import url_for +from lnurl import ( + LnurlPayResponse, + LnurlPayActionResponse, + LnurlErrorResponse, +) # type: ignore from lnbits.core.services import create_invoice -from lnbits.extensions.offlineshop.models import Item from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from . import offlineshop_ext -from .crud import get_item, get_shop +from .crud import get_shop, get_item @offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response") @@ -26,7 +27,7 @@ async def lnurl_response(req: Request, item_id: int = Query(...)): return {"status": "ERROR", "reason": "Item disabled."} price_msat = ( - await fiat_amount_as_satoshis(item.price / 1000, item.unit) + await fiat_amount_as_satoshis(item.price, item.unit) if item.unit != "sat" else item.price ) * 1000 @@ -46,12 +47,12 @@ async def lnurl_callback(request: Request, item_id: int): item = await get_item(item_id) # type: Item if not item: return {"status": "ERROR", "reason": "Couldn't find item."} - + if item.unit == "sat": min = item.price * 1000 max = item.price * 1000 else: - price = await fiat_amount_as_satoshis(item.price / 1000, item.unit) + price = await fiat_amount_as_satoshis(item.price, item.unit) # allow some fluctuation (the fiat price may have changed between the calls) min = price * 995 max = price * 1010 @@ -67,6 +68,7 @@ async def lnurl_callback(request: Request, item_id: int): ).dict() shop = await get_shop(item.shop) + try: payment_hash, payment_request = await create_invoice( wallet_id=shop.wallet, diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py index 2d86a644..f7c2dfec 100644 --- a/lnbits/extensions/offlineshop/migrations.py +++ b/lnbits/extensions/offlineshop/migrations.py @@ -27,6 +27,3 @@ async def m001_initial(db): ); """ ) - -async def m002_store_fiat_in_cents(db): - await db.execute("UPDATE offlineshop.items SET price = (price * 1000) WHERE unit NOT LIKE 'sat'") diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js index 7be1b26f..00e93241 100644 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -31,7 +31,7 @@ new Vue({ computed: { printItems() { return this.offlineshop.items.filter(({enabled}) => enabled) - }, + } }, methods: { openNewDialog() { @@ -119,9 +119,6 @@ new Vue({ }, async sendItem() { let {id, name, image, description, price, unit} = this.itemDialog.data - //convert fiat price to milli (int) - price = unit == 'sat' ? price : price * 1000 - const data = { name, description, diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py index fb771166..748d2024 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -3,17 +3,17 @@ from datetime import datetime from http import HTTPStatus from typing import List -from fastapi import HTTPException, Request from fastapi.params import Depends, Query from starlette.responses import HTMLResponse -from lnbits.core.crud import get_standalone_payment -from lnbits.core.models import Payment, User from lnbits.decorators import check_user_exists +from lnbits.core.models import Payment, User +from lnbits.core.crud import get_standalone_payment from . import offlineshop_ext, offlineshop_renderer -from .crud import get_item, get_shop from .models import Item +from .crud import get_item, get_shop +from fastapi import Request, HTTPException @offlineshop_ext.get("/", response_class=HTMLResponse) @@ -29,8 +29,6 @@ async def print_qr_codes(request: Request, items: List[int] = None): for item_id in request.query_params.get("items").split(","): item = await get_item(item_id) # type: Item if item: - if item.unit != 'sat': - item.price = (item.price / 1000) items.append( { "lnurl": item.lnurl(request), diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index afb7d7ee..90652651 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -27,7 +27,7 @@ from .models import ShopCounter @offlineshop_ext.get("/api/v1/currencies") async def api_list_currencies_available(): - return list(currencies.keys()) + return json.dumps(list(currencies.keys())) @offlineshop_ext.get("/api/v1/offlineshop") @@ -37,12 +37,7 @@ async def api_shop_from_wallet( ): shop = await get_or_create_shop_by_wallet(wallet.wallet.id) items = await get_items(shop.id) - - #revert millicents to unit - for item in items: - if item.unit != 'sat': - item.price = item.price / 1000 - + try: return { **shop.dict(), @@ -65,11 +60,11 @@ class CreateItemsData(BaseModel): @offlineshop_ext.post("/api/v1/offlineshop/items") @offlineshop_ext.put("/api/v1/offlineshop/items/{item_id}") +# @api_check_wallet_key("invoice") async def api_add_or_update_item( data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type) ): - shop = await get_or_create_shop_by_wallet(wallet.wallet.id) - + shop = await get_or_create_shop_by_wallet(wallet.wallet.id) if item_id == None: await add_item( shop.id, data.name, data.description, data.image, data.price, data.unit From 8bf14ca2c7f5cfc4b246a8b26ef17c9f4ecadf75 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 25 Oct 2021 18:25:57 +0100 Subject: [PATCH 13/27] Revert "proposal to allow decimal valued items in fiat" This reverts commit e58a6923fcdfc3fe06ee21a23b98ecae80e3a69e. From 55023fa85b22f3031f178e13cc20cd9075a5ab0c Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Mon, 25 Oct 2021 19:26:21 +0100 Subject: [PATCH 14/27] fix delete wallet --- lnbits/core/views/generic.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py index 173e41ee..5e0ededf 100644 --- a/lnbits/core/views/generic.py +++ b/lnbits/core/views/generic.py @@ -1,5 +1,4 @@ import asyncio - from http import HTTPStatus from typing import Optional @@ -13,10 +12,10 @@ from pydantic.types import UUID4 from starlette.responses import HTMLResponse from lnbits.core import db -from lnbits.helpers import template_renderer, url_for -from lnbits.requestvars import g from lnbits.core.models import User from lnbits.decorators import check_user_exists +from lnbits.helpers import template_renderer, url_for +from lnbits.requestvars import g from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_SITE_TITLE, SERVICE_FEE from ..crud import ( @@ -189,21 +188,20 @@ async def lnurl_full_withdraw_callback(request: Request): @core_html_routes.get("/deletewallet") -# @validate_uuids(["usr", "wal"], required=True) -# @check_user_exists() -async def deletewallet(request: Request): - wallet_id = request.path_params.get("wal", type=str) - user_wallet_ids = g().user.wallet_ids +async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)): + user = await get_user(usr) + user_wallet_ids = [u.id for u in user.wallets] + print("USR", user_wallet_ids) - if wallet_id not in user_wallet_ids: + if wal not in user_wallet_ids: raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.") else: - await delete_wallet(user_id=g().user.id, wallet_id=wallet_id) - user_wallet_ids.remove(wallet_id) + await delete_wallet(user_id=user.id, wallet_id=wal) + user_wallet_ids.remove(wal) if user_wallet_ids: return RedirectResponse( - url_for("/wallet", usr=g().user.id, wal=user_wallet_ids[0]), + url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]), status_code=status.HTTP_307_TEMPORARY_REDIRECT, ) From 4653e81695f3cc828a208c723f002682eb1e17ed Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Tue, 26 Oct 2021 16:48:04 +0100 Subject: [PATCH 15/27] blesko initial commit --- lnbits/extensions/bleskomat/README.md | 21 ++ lnbits/extensions/bleskomat/__init__.py | 12 + lnbits/extensions/bleskomat/config.json | 6 + lnbits/extensions/bleskomat/crud.py | 119 ++++++++++ lnbits/extensions/bleskomat/exchange_rates.py | 79 +++++++ .../extensions/bleskomat/fiat_currencies.json | 166 ++++++++++++++ lnbits/extensions/bleskomat/helpers.py | 153 +++++++++++++ lnbits/extensions/bleskomat/lnurl_api.py | 134 +++++++++++ lnbits/extensions/bleskomat/migrations.py | 37 +++ lnbits/extensions/bleskomat/models.py | 110 +++++++++ .../extensions/bleskomat/static/js/index.js | 216 ++++++++++++++++++ .../templates/bleskomat/_api_docs.html | 65 ++++++ .../bleskomat/templates/bleskomat/index.html | 180 +++++++++++++++ lnbits/extensions/bleskomat/views.py | 22 ++ lnbits/extensions/bleskomat/views_api.py | 120 ++++++++++ 15 files changed, 1440 insertions(+) create mode 100644 lnbits/extensions/bleskomat/README.md create mode 100644 lnbits/extensions/bleskomat/__init__.py create mode 100644 lnbits/extensions/bleskomat/config.json create mode 100644 lnbits/extensions/bleskomat/crud.py create mode 100644 lnbits/extensions/bleskomat/exchange_rates.py create mode 100644 lnbits/extensions/bleskomat/fiat_currencies.json create mode 100644 lnbits/extensions/bleskomat/helpers.py create mode 100644 lnbits/extensions/bleskomat/lnurl_api.py create mode 100644 lnbits/extensions/bleskomat/migrations.py create mode 100644 lnbits/extensions/bleskomat/models.py create mode 100644 lnbits/extensions/bleskomat/static/js/index.js create mode 100644 lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html create mode 100644 lnbits/extensions/bleskomat/templates/bleskomat/index.html create mode 100644 lnbits/extensions/bleskomat/views.py create mode 100644 lnbits/extensions/bleskomat/views_api.py diff --git a/lnbits/extensions/bleskomat/README.md b/lnbits/extensions/bleskomat/README.md new file mode 100644 index 00000000..97c70700 --- /dev/null +++ b/lnbits/extensions/bleskomat/README.md @@ -0,0 +1,21 @@ +# Bleskomat Extension for lnbits + +This extension allows you to connect a Bleskomat ATM to an lnbits wallet. It will work with both the [open-source DIY Bleskomat ATM project](https://github.com/samotari/bleskomat) as well as the [commercial Bleskomat ATM](https://www.bleskomat.com/). + + +## Connect Your Bleskomat ATM + +* Click the "Add Bleskomat" button on this page to begin. +* Choose a wallet. This will be the wallet that is used to pay satoshis to your ATM customers. +* Choose the fiat currency. This should match the fiat currency that your ATM accepts. +* Pick an exchange rate provider. This is the API that will be used to query the fiat to satoshi exchange rate at the time your customer attempts to withdraw their funds. +* Set your ATM's fee percentage. +* Click the "Done" button. +* Find the new Bleskomat in the list and then click the export icon to download a new configuration file for your ATM. +* Copy the configuration file ("bleskomat.conf") to your ATM's SD card. +* Restart Your Bleskomat ATM. It should automatically reload the configurations from the SD card. + + +## How Does It Work? + +Since the Bleskomat ATMs are designed to be offline, a cryptographic signing scheme is used to verify that the URL was generated by an authorized device. When one of your customers inserts fiat money into the device, a signed URL (lnurl-withdraw) is created and displayed as a QR code. Your customer scans the QR code with their lnurl-supporting mobile app, their mobile app communicates with the web API of lnbits to verify the signature, the fiat currency amount is converted to sats, the customer accepts the withdrawal, and finally lnbits will pay the customer from your lnbits wallet. diff --git a/lnbits/extensions/bleskomat/__init__.py b/lnbits/extensions/bleskomat/__init__.py new file mode 100644 index 00000000..42f9bb46 --- /dev/null +++ b/lnbits/extensions/bleskomat/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_bleskomat") + +bleskomat_ext: Blueprint = Blueprint( + "bleskomat", __name__, static_folder="static", template_folder="templates" +) + +from .lnurl_api import * # noqa +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/bleskomat/config.json b/lnbits/extensions/bleskomat/config.json new file mode 100644 index 00000000..99244df1 --- /dev/null +++ b/lnbits/extensions/bleskomat/config.json @@ -0,0 +1,6 @@ +{ + "name": "Bleskomat", + "short_description": "Connect a Bleskomat ATM to an lnbits", + "icon": "money", + "contributors": ["chill117"] +} diff --git a/lnbits/extensions/bleskomat/crud.py b/lnbits/extensions/bleskomat/crud.py new file mode 100644 index 00000000..1cc44576 --- /dev/null +++ b/lnbits/extensions/bleskomat/crud.py @@ -0,0 +1,119 @@ +import secrets +import time +from uuid import uuid4 +from typing import List, Optional, Union +from . import db +from .models import Bleskomat, BleskomatLnurl +from .helpers import generate_bleskomat_lnurl_hash + + +async def create_bleskomat( + *, + wallet_id: str, + name: str, + fiat_currency: str, + exchange_rate_provider: str, + fee: str, +) -> Bleskomat: + bleskomat_id = uuid4().hex + api_key_id = secrets.token_hex(8) + api_key_secret = secrets.token_hex(32) + api_key_encoding = "hex" + await db.execute( + """ + INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bleskomat_id, + wallet_id, + api_key_id, + api_key_secret, + api_key_encoding, + name, + fiat_currency, + exchange_rate_provider, + fee, + ), + ) + bleskomat = await get_bleskomat(bleskomat_id) + assert bleskomat, "Newly created bleskomat couldn't be retrieved" + return bleskomat + + +async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]: + row = await db.fetchone( + "SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,) + ) + return Bleskomat(**row) if row else None + + +async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]: + row = await db.fetchone( + "SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,) + ) + return Bleskomat(**row) if row else None + + +async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,) + ) + return [Bleskomat(**row) for row in rows] + + +async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?", + (*kwargs.values(), bleskomat_id), + ) + row = await db.fetchone( + "SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,) + ) + return Bleskomat(**row) if row else None + + +async def delete_bleskomat(bleskomat_id: str) -> None: + await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)) + + +async def create_bleskomat_lnurl( + *, bleskomat: Bleskomat, secret: str, tag: str, params: str, uses: int = 1 +) -> BleskomatLnurl: + bleskomat_lnurl_id = uuid4().hex + hash = generate_bleskomat_lnurl_hash(secret) + now = int(time.time()) + await db.execute( + """ + INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + bleskomat_lnurl_id, + bleskomat.id, + bleskomat.wallet, + hash, + tag, + params, + bleskomat.api_key_id, + uses, + uses, + now, + now, + ), + ) + bleskomat_lnurl = await get_bleskomat_lnurl(secret) + assert bleskomat_lnurl, "Newly created bleskomat LNURL couldn't be retrieved" + return bleskomat_lnurl + + +async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]: + hash = generate_bleskomat_lnurl_hash(secret) + row = await db.fetchone( + "SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,) + ) + return BleskomatLnurl(**row) if row else None diff --git a/lnbits/extensions/bleskomat/exchange_rates.py b/lnbits/extensions/bleskomat/exchange_rates.py new file mode 100644 index 00000000..928a2823 --- /dev/null +++ b/lnbits/extensions/bleskomat/exchange_rates.py @@ -0,0 +1,79 @@ +import httpx +import json +import os + +fiat_currencies = json.load( + open( + os.path.join( + os.path.dirname(os.path.realpath(__file__)), "fiat_currencies.json" + ), + "r", + ) +) + +exchange_rate_providers = { + "bitfinex": { + "name": "Bitfinex", + "domain": "bitfinex.com", + "api_url": "https://api.bitfinex.com/v1/pubticker/{from}{to}", + "getter": lambda data, replacements: data["last_price"], + }, + "bitstamp": { + "name": "Bitstamp", + "domain": "bitstamp.net", + "api_url": "https://www.bitstamp.net/api/v2/ticker/{from}{to}/", + "getter": lambda data, replacements: data["last"], + }, + "coinbase": { + "name": "Coinbase", + "domain": "coinbase.com", + "api_url": "https://api.coinbase.com/v2/exchange-rates?currency={FROM}", + "getter": lambda data, replacements: data["data"]["rates"][replacements["TO"]], + }, + "coinmate": { + "name": "CoinMate", + "domain": "coinmate.io", + "api_url": "https://coinmate.io/api/ticker?currencyPair={FROM}_{TO}", + "getter": lambda data, replacements: data["data"]["last"], + }, + "kraken": { + "name": "Kraken", + "domain": "kraken.com", + "api_url": "https://api.kraken.com/0/public/Ticker?pair=XBT{TO}", + "getter": lambda data, replacements: data["result"][ + "XXBTZ" + replacements["TO"] + ]["c"][0], + }, +} + +exchange_rate_providers_serializable = {} +for ref, exchange_rate_provider in exchange_rate_providers.items(): + exchange_rate_provider_serializable = {} + for key, value in exchange_rate_provider.items(): + if not callable(value): + exchange_rate_provider_serializable[key] = value + exchange_rate_providers_serializable[ref] = exchange_rate_provider_serializable + + +async def fetch_fiat_exchange_rate(currency: str, provider: str): + + replacements = { + "FROM": "BTC", + "from": "btc", + "TO": currency.upper(), + "to": currency.lower(), + } + + url = exchange_rate_providers[provider]["api_url"] + for key in replacements.keys(): + url = url.replace("{" + key + "}", replacements[key]) + + getter = exchange_rate_providers[provider]["getter"] + + async with httpx.AsyncClient() as client: + r = await client.get(url) + r.raise_for_status() + data = r.json() + rate = float(getter(data, replacements)) + + return rate diff --git a/lnbits/extensions/bleskomat/fiat_currencies.json b/lnbits/extensions/bleskomat/fiat_currencies.json new file mode 100644 index 00000000..ff831f3e --- /dev/null +++ b/lnbits/extensions/bleskomat/fiat_currencies.json @@ -0,0 +1,166 @@ +{ + "AED": "United Arab Emirates Dirham", + "AFN": "Afghan Afghani", + "ALL": "Albanian Lek", + "AMD": "Armenian Dram", + "ANG": "Netherlands Antillean Gulden", + "AOA": "Angolan Kwanza", + "ARS": "Argentine Peso", + "AUD": "Australian Dollar", + "AWG": "Aruban Florin", + "AZN": "Azerbaijani Manat", + "BAM": "Bosnia and Herzegovina Convertible Mark", + "BBD": "Barbadian Dollar", + "BDT": "Bangladeshi Taka", + "BGN": "Bulgarian Lev", + "BHD": "Bahraini Dinar", + "BIF": "Burundian Franc", + "BMD": "Bermudian Dollar", + "BND": "Brunei Dollar", + "BOB": "Bolivian Boliviano", + "BRL": "Brazilian Real", + "BSD": "Bahamian Dollar", + "BTN": "Bhutanese Ngultrum", + "BWP": "Botswana Pula", + "BYN": "Belarusian Ruble", + "BYR": "Belarusian Ruble", + "BZD": "Belize Dollar", + "CAD": "Canadian Dollar", + "CDF": "Congolese Franc", + "CHF": "Swiss Franc", + "CLF": "Unidad de Fomento", + "CLP": "Chilean Peso", + "CNH": "Chinese Renminbi Yuan Offshore", + "CNY": "Chinese Renminbi Yuan", + "COP": "Colombian Peso", + "CRC": "Costa Rican Colón", + "CUC": "Cuban Convertible Peso", + "CVE": "Cape Verdean Escudo", + "CZK": "Czech Koruna", + "DJF": "Djiboutian Franc", + "DKK": "Danish Krone", + "DOP": "Dominican Peso", + "DZD": "Algerian Dinar", + "EGP": "Egyptian Pound", + "ERN": "Eritrean Nakfa", + "ETB": "Ethiopian Birr", + "EUR": "Euro", + "FJD": "Fijian Dollar", + "FKP": "Falkland Pound", + "GBP": "British Pound", + "GEL": "Georgian Lari", + "GGP": "Guernsey Pound", + "GHS": "Ghanaian Cedi", + "GIP": "Gibraltar Pound", + "GMD": "Gambian Dalasi", + "GNF": "Guinean Franc", + "GTQ": "Guatemalan Quetzal", + "GYD": "Guyanese Dollar", + "HKD": "Hong Kong Dollar", + "HNL": "Honduran Lempira", + "HRK": "Croatian Kuna", + "HTG": "Haitian Gourde", + "HUF": "Hungarian Forint", + "IDR": "Indonesian Rupiah", + "ILS": "Israeli New Sheqel", + "IMP": "Isle of Man Pound", + "INR": "Indian Rupee", + "IQD": "Iraqi Dinar", + "ISK": "Icelandic Króna", + "JEP": "Jersey Pound", + "JMD": "Jamaican Dollar", + "JOD": "Jordanian Dinar", + "JPY": "Japanese Yen", + "KES": "Kenyan Shilling", + "KGS": "Kyrgyzstani Som", + "KHR": "Cambodian Riel", + "KMF": "Comorian Franc", + "KRW": "South Korean Won", + "KWD": "Kuwaiti Dinar", + "KYD": "Cayman Islands Dollar", + "KZT": "Kazakhstani Tenge", + "LAK": "Lao Kip", + "LBP": "Lebanese Pound", + "LKR": "Sri Lankan Rupee", + "LRD": "Liberian Dollar", + "LSL": "Lesotho Loti", + "LYD": "Libyan Dinar", + "MAD": "Moroccan Dirham", + "MDL": "Moldovan Leu", + "MGA": "Malagasy Ariary", + "MKD": "Macedonian Denar", + "MMK": "Myanmar Kyat", + "MNT": "Mongolian Tögrög", + "MOP": "Macanese Pataca", + "MRO": "Mauritanian Ouguiya", + "MUR": "Mauritian Rupee", + "MVR": "Maldivian Rufiyaa", + "MWK": "Malawian Kwacha", + "MXN": "Mexican Peso", + "MYR": "Malaysian Ringgit", + "MZN": "Mozambican Metical", + "NAD": "Namibian Dollar", + "NGN": "Nigerian Naira", + "NIO": "Nicaraguan Córdoba", + "NOK": "Norwegian Krone", + "NPR": "Nepalese Rupee", + "NZD": "New Zealand Dollar", + "OMR": "Omani Rial", + "PAB": "Panamanian Balboa", + "PEN": "Peruvian Sol", + "PGK": "Papua New Guinean Kina", + "PHP": "Philippine Peso", + "PKR": "Pakistani Rupee", + "PLN": "Polish Złoty", + "PYG": "Paraguayan Guaraní", + "QAR": "Qatari Riyal", + "RON": "Romanian Leu", + "RSD": "Serbian Dinar", + "RUB": "Russian Ruble", + "RWF": "Rwandan Franc", + "SAR": "Saudi Riyal", + "SBD": "Solomon Islands Dollar", + "SCR": "Seychellois Rupee", + "SEK": "Swedish Krona", + "SGD": "Singapore Dollar", + "SHP": "Saint Helenian Pound", + "SLL": "Sierra Leonean Leone", + "SOS": "Somali Shilling", + "SRD": "Surinamese Dollar", + "SSP": "South Sudanese Pound", + "STD": "São Tomé and Príncipe Dobra", + "SVC": "Salvadoran Colón", + "SZL": "Swazi Lilangeni", + "THB": "Thai Baht", + "TJS": "Tajikistani Somoni", + "TMT": "Turkmenistani Manat", + "TND": "Tunisian Dinar", + "TOP": "Tongan Paʻanga", + "TRY": "Turkish Lira", + "TTD": "Trinidad and Tobago Dollar", + "TWD": "New Taiwan Dollar", + "TZS": "Tanzanian Shilling", + "UAH": "Ukrainian Hryvnia", + "UGX": "Ugandan Shilling", + "USD": "US Dollar", + "UYU": "Uruguayan Peso", + "UZS": "Uzbekistan Som", + "VEF": "Venezuelan Bolívar", + "VES": "Venezuelan Bolívar Soberano", + "VND": "Vietnamese Đồng", + "VUV": "Vanuatu Vatu", + "WST": "Samoan Tala", + "XAF": "Central African Cfa Franc", + "XAG": "Silver (Troy Ounce)", + "XAU": "Gold (Troy Ounce)", + "XCD": "East Caribbean Dollar", + "XDR": "Special Drawing Rights", + "XOF": "West African Cfa Franc", + "XPD": "Palladium", + "XPF": "Cfp Franc", + "XPT": "Platinum", + "YER": "Yemeni Rial", + "ZAR": "South African Rand", + "ZMW": "Zambian Kwacha", + "ZWL": "Zimbabwean Dollar" +} \ No newline at end of file diff --git a/lnbits/extensions/bleskomat/helpers.py b/lnbits/extensions/bleskomat/helpers.py new file mode 100644 index 00000000..a3857b77 --- /dev/null +++ b/lnbits/extensions/bleskomat/helpers.py @@ -0,0 +1,153 @@ +import base64 +import hashlib +import hmac +from http import HTTPStatus +from binascii import unhexlify +from typing import Dict +from quart import url_for +import urllib + + +def generate_bleskomat_lnurl_hash(secret: str): + m = hashlib.sha256() + m.update(f"{secret}".encode()) + return m.hexdigest() + + +def generate_bleskomat_lnurl_signature( + payload: str, api_key_secret: str, api_key_encoding: str = "hex" +): + if api_key_encoding == "hex": + key = unhexlify(api_key_secret) + elif api_key_encoding == "base64": + key = base64.b64decode(api_key_secret) + else: + key = bytes(f"{api_key_secret}") + return hmac.new(key=key, msg=payload.encode(), digestmod=hashlib.sha256).hexdigest() + + +def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str): + # The secret is not randomly generated by the server. + # Instead it is the hash of the API key ID and signature concatenated together. + m = hashlib.sha256() + m.update(f"{api_key_id}-{signature}".encode()) + return m.hexdigest() + + +def get_callback_url(): + return url_for("bleskomat.api_bleskomat_lnurl", _external=True) + + +def is_supported_lnurl_subprotocol(tag: str) -> bool: + return tag == "withdrawRequest" + + +class LnurlHttpError(Exception): + def __init__( + self, + message: str = "", + http_status: HTTPStatus = HTTPStatus.INTERNAL_SERVER_ERROR, + ): + self.message = message + self.http_status = http_status + super().__init__(self.message) + + +class LnurlValidationError(Exception): + pass + + +def prepare_lnurl_params(tag: str, query: Dict[str, str]): + params = {} + if not is_supported_lnurl_subprotocol(tag): + raise LnurlValidationError(f'Unsupported subprotocol: "{tag}"') + if tag == "withdrawRequest": + params["minWithdrawable"] = float(query["minWithdrawable"]) + params["maxWithdrawable"] = float(query["maxWithdrawable"]) + params["defaultDescription"] = query["defaultDescription"] + if not params["minWithdrawable"] > 0: + raise LnurlValidationError('"minWithdrawable" must be greater than zero') + if not params["maxWithdrawable"] >= params["minWithdrawable"]: + raise LnurlValidationError( + '"maxWithdrawable" must be greater than or equal to "minWithdrawable"' + ) + return params + + +encode_uri_component_safe_chars = ( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!~*'()" +) + + +def query_to_signing_payload(query: Dict[str, str]) -> str: + # Sort the query by key, then stringify it to create the payload. + sorted_keys = sorted(query.keys(), key=str.lower) + payload = [] + for key in sorted_keys: + if not key == "signature": + encoded_key = urllib.parse.quote(key, safe=encode_uri_component_safe_chars) + encoded_value = urllib.parse.quote( + query[key], safe=encode_uri_component_safe_chars + ) + payload.append(f"{encoded_key}={encoded_value}") + return "&".join(payload) + + +unshorten_rules = { + "query": {"n": "nonce", "s": "signature", "t": "tag"}, + "tags": { + "c": "channelRequest", + "l": "login", + "p": "payRequest", + "w": "withdrawRequest", + }, + "params": { + "channelRequest": {"pl": "localAmt", "pp": "pushAmt"}, + "login": {}, + "payRequest": {"pn": "minSendable", "px": "maxSendable", "pm": "metadata"}, + "withdrawRequest": { + "pn": "minWithdrawable", + "px": "maxWithdrawable", + "pd": "defaultDescription", + }, + }, +} + + +def unshorten_lnurl_query(query: Dict[str, str]) -> Dict[str, str]: + new_query = {} + rules = unshorten_rules + if "tag" in query: + tag = query["tag"] + elif "t" in query: + tag = query["t"] + else: + raise LnurlValidationError('Missing required query parameter: "tag"') + # Unshorten tag: + if tag in rules["tags"]: + long_tag = rules["tags"][tag] + new_query["tag"] = long_tag + tag = long_tag + if not tag in rules["params"]: + raise LnurlValidationError(f'Unknown tag: "{tag}"') + for key in query: + if key in rules["params"][tag]: + short_param_key = key + long_param_key = rules["params"][tag][short_param_key] + if short_param_key in query: + new_query[long_param_key] = query[short_param_key] + else: + new_query[long_param_key] = query[long_param_key] + elif key in rules["query"]: + # Unshorten general keys: + short_key = key + long_key = rules["query"][short_key] + if not long_key in new_query: + if short_key in query: + new_query[long_key] = query[short_key] + else: + new_query[long_key] = query[long_key] + else: + # Keep unknown key/value pairs unchanged: + new_query[key] = query[key] + return new_query diff --git a/lnbits/extensions/bleskomat/lnurl_api.py b/lnbits/extensions/bleskomat/lnurl_api.py new file mode 100644 index 00000000..086562d1 --- /dev/null +++ b/lnbits/extensions/bleskomat/lnurl_api.py @@ -0,0 +1,134 @@ +import json +import math +from quart import jsonify, request +from http import HTTPStatus +import traceback + +from . import bleskomat_ext +from .crud import ( + create_bleskomat_lnurl, + get_bleskomat_by_api_key_id, + get_bleskomat_lnurl, +) + +from .exchange_rates import ( + fetch_fiat_exchange_rate, +) + +from .helpers import ( + generate_bleskomat_lnurl_signature, + generate_bleskomat_lnurl_secret, + LnurlHttpError, + LnurlValidationError, + prepare_lnurl_params, + query_to_signing_payload, + unshorten_lnurl_query, +) + + +# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs. +@bleskomat_ext.route("/u", methods=["GET"]) +async def api_bleskomat_lnurl(): + try: + query = request.args.to_dict() + + # Unshorten query if "s" is used instead of "signature". + if "s" in query: + query = unshorten_lnurl_query(query) + + if "signature" in query: + + # Signature provided. + # Use signature to verify that the URL was generated by an authorized device. + # Later validate parameters, auto-generate LNURL, reply with LNURL response object. + signature = query["signature"] + + # The API key ID, nonce, and tag should be present in the query string. + for field in ["id", "nonce", "tag"]: + if not field in query: + raise LnurlHttpError( + f'Failed API key signature check: Missing "{field}"', + HTTPStatus.BAD_REQUEST, + ) + + # URL signing scheme is described here: + # https://github.com/chill117/lnurl-node#how-to-implement-url-signing-scheme + payload = query_to_signing_payload(query) + api_key_id = query["id"] + bleskomat = await get_bleskomat_by_api_key_id(api_key_id) + if not bleskomat: + raise LnurlHttpError("Unknown API key", HTTPStatus.BAD_REQUEST) + api_key_secret = bleskomat.api_key_secret + api_key_encoding = bleskomat.api_key_encoding + expected_signature = generate_bleskomat_lnurl_signature( + payload, api_key_secret, api_key_encoding + ) + if signature != expected_signature: + raise LnurlHttpError("Invalid API key signature", HTTPStatus.FORBIDDEN) + + # Signature is valid. + # In the case of signed URLs, the secret is deterministic based on the API key ID and signature. + secret = generate_bleskomat_lnurl_secret(api_key_id, signature) + lnurl = await get_bleskomat_lnurl(secret) + if not lnurl: + try: + tag = query["tag"] + params = prepare_lnurl_params(tag, query) + if "f" in query: + rate = await fetch_fiat_exchange_rate( + currency=query["f"], + provider=bleskomat.exchange_rate_provider, + ) + # Convert fee (%) to decimal: + fee = float(bleskomat.fee) / 100 + if tag == "withdrawRequest": + for key in ["minWithdrawable", "maxWithdrawable"]: + amount_sats = int( + math.floor((params[key] / rate) * 1e8) + ) + fee_sats = int(math.floor(amount_sats * fee)) + amount_sats_less_fee = amount_sats - fee_sats + # Convert to msats: + params[key] = int(amount_sats_less_fee * 1e3) + except LnurlValidationError as e: + raise LnurlHttpError(e.message, HTTPStatus.BAD_REQUEST) + # Create a new LNURL using the query parameters provided in the signed URL. + params = json.JSONEncoder().encode(params) + lnurl = await create_bleskomat_lnurl( + bleskomat=bleskomat, secret=secret, tag=tag, params=params, uses=1 + ) + + # Reply with LNURL response object. + return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK + + # No signature provided. + # Treat as "action" callback. + + if not "k1" in query: + raise LnurlHttpError("Missing secret", HTTPStatus.BAD_REQUEST) + + secret = query["k1"] + lnurl = await get_bleskomat_lnurl(secret) + if not lnurl: + raise LnurlHttpError("Invalid secret", HTTPStatus.BAD_REQUEST) + + if not lnurl.has_uses_remaining(): + raise LnurlHttpError( + "Maximum number of uses already reached", HTTPStatus.BAD_REQUEST + ) + + try: + await lnurl.execute_action(query) + except LnurlValidationError as e: + raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST) + + except LnurlHttpError as e: + return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status + except Exception: + traceback.print_exc() + return ( + jsonify({"status": "ERROR", "reason": "Unexpected error"}), + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return jsonify({"status": "OK"}), HTTPStatus.OK diff --git a/lnbits/extensions/bleskomat/migrations.py b/lnbits/extensions/bleskomat/migrations.py new file mode 100644 index 00000000..84e886e5 --- /dev/null +++ b/lnbits/extensions/bleskomat/migrations.py @@ -0,0 +1,37 @@ +async def m001_initial(db): + + await db.execute( + """ + CREATE TABLE bleskomat.bleskomats ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + api_key_id TEXT NOT NULL, + api_key_secret TEXT NOT NULL, + api_key_encoding TEXT NOT NULL, + name TEXT NOT NULL, + fiat_currency TEXT NOT NULL, + exchange_rate_provider TEXT NOT NULL, + fee TEXT NOT NULL, + UNIQUE(api_key_id) + ); + """ + ) + + await db.execute( + """ + CREATE TABLE bleskomat.bleskomat_lnurls ( + id TEXT PRIMARY KEY, + bleskomat TEXT NOT NULL, + wallet TEXT NOT NULL, + hash TEXT NOT NULL, + tag TEXT NOT NULL, + params TEXT NOT NULL, + api_key_id TEXT NOT NULL, + initial_uses INTEGER DEFAULT 1, + remaining_uses INTEGER DEFAULT 0, + created_time INTEGER, + updated_time INTEGER, + UNIQUE(hash) + ); + """ + ) diff --git a/lnbits/extensions/bleskomat/models.py b/lnbits/extensions/bleskomat/models.py new file mode 100644 index 00000000..216f83c6 --- /dev/null +++ b/lnbits/extensions/bleskomat/models.py @@ -0,0 +1,110 @@ +import json +import time +from typing import NamedTuple, Dict +from lnbits import bolt11 +from lnbits.core.services import pay_invoice +from . import db +from .helpers import get_callback_url, LnurlValidationError + + +class Bleskomat(NamedTuple): + id: str + wallet: str + api_key_id: str + api_key_secret: str + api_key_encoding: str + name: str + fiat_currency: str + exchange_rate_provider: str + fee: str + + +class BleskomatLnurl(NamedTuple): + id: str + bleskomat: str + wallet: str + hash: str + tag: str + params: str + api_key_id: str + initial_uses: int + remaining_uses: int + created_time: int + updated_time: int + + def has_uses_remaining(self) -> bool: + # When initial uses is 0 then the LNURL has unlimited uses. + return self.initial_uses == 0 or self.remaining_uses > 0 + + def get_info_response_object(self, secret: str) -> Dict[str, str]: + tag = self.tag + params = json.loads(self.params) + response = {"tag": tag} + if tag == "withdrawRequest": + for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]: + response[key] = params[key] + response["callback"] = get_callback_url() + response["k1"] = secret + return response + + def validate_action(self, query: Dict[str, str]) -> None: + tag = self.tag + params = json.loads(self.params) + # Perform tag-specific checks. + if tag == "withdrawRequest": + for field in ["pr"]: + if not field in query: + raise LnurlValidationError(f'Missing required parameter: "{field}"') + # Check the bolt11 invoice(s) provided. + pr = query["pr"] + if "," in pr: + raise LnurlValidationError("Multiple payment requests not supported") + try: + invoice = bolt11.decode(pr) + except ValueError: + raise LnurlValidationError( + 'Invalid parameter ("pr"): Lightning payment request expected' + ) + if invoice.amount_msat < params["minWithdrawable"]: + raise LnurlValidationError( + 'Amount in invoice must be greater than or equal to "minWithdrawable"' + ) + if invoice.amount_msat > params["maxWithdrawable"]: + raise LnurlValidationError( + 'Amount in invoice must be less than or equal to "maxWithdrawable"' + ) + else: + raise LnurlValidationError(f'Unknown subprotocol: "{tag}"') + + async def execute_action(self, query: Dict[str, str]): + self.validate_action(query) + used = False + async with db.connect() as conn: + if self.initial_uses > 0: + used = await self.use(conn) + if not used: + raise LnurlValidationError("Maximum number of uses already reached") + tag = self.tag + if tag == "withdrawRequest": + try: + payment_hash = await pay_invoice( + wallet_id=self.wallet, + payment_request=query["pr"], + ) + except Exception: + raise LnurlValidationError("Failed to pay invoice") + if not payment_hash: + raise LnurlValidationError("Failed to pay invoice") + + async def use(self, conn) -> bool: + now = int(time.time()) + result = await conn.execute( + """ + UPDATE bleskomat.bleskomat_lnurls + SET remaining_uses = remaining_uses - 1, updated_time = ? + WHERE id = ? + AND remaining_uses > 0 + """, + (now, self.id), + ) + return result.rowcount > 0 diff --git a/lnbits/extensions/bleskomat/static/js/index.js b/lnbits/extensions/bleskomat/static/js/index.js new file mode 100644 index 00000000..fd166ff3 --- /dev/null +++ b/lnbits/extensions/bleskomat/static/js/index.js @@ -0,0 +1,216 @@ +/* global Vue, VueQrcode, _, Quasar, LOCALE, windowMixin, LNbits */ + +Vue.component(VueQrcode.name, VueQrcode) + +var mapBleskomat = function (obj) { + obj._data = _.clone(obj) + return obj +} + +var defaultValues = { + name: 'My Bleskomat', + fiat_currency: 'EUR', + exchange_rate_provider: 'coinbase', + fee: '0.00' +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data: function () { + return { + checker: null, + bleskomats: [], + bleskomatsTable: { + columns: [ + { + name: 'api_key_id', + align: 'left', + label: 'API Key ID', + field: 'api_key_id' + }, + { + name: 'name', + align: 'left', + label: 'Name', + field: 'name' + }, + { + name: 'fiat_currency', + align: 'left', + label: 'Fiat Currency', + field: 'fiat_currency' + }, + { + name: 'exchange_rate_provider', + align: 'left', + label: 'Exchange Rate Provider', + field: 'exchange_rate_provider' + }, + { + name: 'fee', + align: 'left', + label: 'Fee (%)', + field: 'fee' + } + ], + pagination: { + rowsPerPage: 10 + } + }, + formDialog: { + show: false, + fiatCurrencies: _.keys(window.bleskomat_vars.fiat_currencies), + exchangeRateProviders: _.keys( + window.bleskomat_vars.exchange_rate_providers + ), + data: _.clone(defaultValues) + } + } + }, + computed: { + sortedBleskomats: function () { + return this.bleskomats.sort(function (a, b) { + // Sort by API Key ID alphabetically. + var apiKeyId_A = a.api_key_id.toLowerCase() + var apiKeyId_B = b.api_key_id.toLowerCase() + return apiKeyId_A < apiKeyId_B ? -1 : apiKeyId_A > apiKeyId_B ? 1 : 0 + }) + } + }, + methods: { + getBleskomats: function () { + var self = this + LNbits.api + .request( + 'GET', + '/bleskomat/api/v1/bleskomats?all_wallets', + this.g.user.wallets[0].adminkey + ) + .then(function (response) { + self.bleskomats = response.data.map(function (obj) { + return mapBleskomat(obj) + }) + }) + .catch(function (error) { + clearInterval(self.checker) + LNbits.utils.notifyApiError(error) + }) + }, + closeFormDialog: function () { + this.formDialog.data = _.clone(defaultValues) + }, + exportConfigFile: function (bleskomatId) { + var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) + var fieldToKey = { + api_key_id: 'apiKey.id', + api_key_secret: 'apiKey.key', + api_key_encoding: 'apiKey.encoding', + fiat_currency: 'fiatCurrency' + } + var lines = _.chain(bleskomat) + .map(function (value, field) { + var key = fieldToKey[field] || null + return key ? [key, value].join('=') : null + }) + .compact() + .value() + lines.push('callbackUrl=' + window.bleskomat_vars.callback_url) + lines.push('shorten=true') + var content = lines.join('\n') + var status = Quasar.utils.exportFile( + 'bleskomat.conf', + content, + 'text/plain' + ) + if (status !== true) { + Quasar.plugins.Notify.create({ + message: 'Browser denied file download...', + color: 'negative', + icon: null + }) + } + }, + openUpdateDialog: function (bleskomatId) { + var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) + this.formDialog.data = _.clone(bleskomat._data) + this.formDialog.show = true + }, + sendFormData: function () { + var wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + var data = _.omit(this.formDialog.data, 'wallet') + if (data.id) { + this.updateBleskomat(wallet, data) + } else { + this.createBleskomat(wallet, data) + } + }, + updateBleskomat: function (wallet, data) { + var self = this + LNbits.api + .request( + 'PUT', + '/bleskomat/api/v1/bleskomat/' + data.id, + wallet.adminkey, + _.pick(data, 'name', 'fiat_currency', 'exchange_rate_provider', 'fee') + ) + .then(function (response) { + self.bleskomats = _.reject(self.bleskomats, function (obj) { + return obj.id === data.id + }) + self.bleskomats.push(mapBleskomat(response.data)) + self.formDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + createBleskomat: function (wallet, data) { + var self = this + LNbits.api + .request('POST', '/bleskomat/api/v1/bleskomat', wallet.adminkey, data) + .then(function (response) { + self.bleskomats.push(mapBleskomat(response.data)) + self.formDialog.show = false + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }, + deleteBleskomat: function (bleskomatId) { + var self = this + var bleskomat = _.findWhere(this.bleskomats, {id: bleskomatId}) + LNbits.utils + .confirmDialog( + 'Are you sure you want to delete "' + bleskomat.name + '"?' + ) + .onOk(function () { + LNbits.api + .request( + 'DELETE', + '/bleskomat/api/v1/bleskomat/' + bleskomatId, + _.findWhere(self.g.user.wallets, {id: bleskomat.wallet}).adminkey + ) + .then(function (response) { + self.bleskomats = _.reject(self.bleskomats, function (obj) { + return obj.id === bleskomatId + }) + }) + .catch(function (error) { + LNbits.utils.notifyApiError(error) + }) + }) + } + }, + created: function () { + if (this.g.user.wallets.length) { + var getBleskomats = this.getBleskomats + getBleskomats() + this.checker = setInterval(function () { + getBleskomats() + }, 20000) + } + } +}) diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html new file mode 100644 index 00000000..210d534c --- /dev/null +++ b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html @@ -0,0 +1,65 @@ + + + +

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

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

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

+
+
+
diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/index.html b/lnbits/extensions/bleskomat/templates/bleskomat/index.html new file mode 100644 index 00000000..0cc51237 --- /dev/null +++ b/lnbits/extensions/bleskomat/templates/bleskomat/index.html @@ -0,0 +1,180 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} {% block page %} +
+
+ + + Add Bleskomat + + + + + +
+
+
Bleskomats
+
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} Bleskomat extension +
+
+ + + {% include "bleskomat/_api_docs.html" %} + +
+
+ + + + + + + + + + + + +
+ Update Bleskomat + Add Bleskomat + Cancel +
+
+
+
+
+{% endblock %} diff --git a/lnbits/extensions/bleskomat/views.py b/lnbits/extensions/bleskomat/views.py new file mode 100644 index 00000000..3a7f7263 --- /dev/null +++ b/lnbits/extensions/bleskomat/views.py @@ -0,0 +1,22 @@ +from quart import g, render_template + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import bleskomat_ext + +from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies +from .helpers import get_callback_url + + +@bleskomat_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + bleskomat_vars = { + "callback_url": get_callback_url(), + "exchange_rate_providers": exchange_rate_providers_serializable, + "fiat_currencies": fiat_currencies, + } + return await render_template( + "bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars + ) diff --git a/lnbits/extensions/bleskomat/views_api.py b/lnbits/extensions/bleskomat/views_api.py new file mode 100644 index 00000000..2971b066 --- /dev/null +++ b/lnbits/extensions/bleskomat/views_api.py @@ -0,0 +1,120 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import bleskomat_ext +from .crud import ( + create_bleskomat, + get_bleskomat, + get_bleskomats, + update_bleskomat, + delete_bleskomat, +) + +from .exchange_rates import ( + exchange_rate_providers, + fetch_fiat_exchange_rate, + fiat_currencies, +) + + +@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_bleskomats(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return ( + jsonify( + [bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)] + ), + HTTPStatus.OK, + ) + + +@bleskomat_ext.route("/api/v1/bleskomat/", methods=["GET"]) +@api_check_wallet_key("admin") +async def api_bleskomat_retrieve(bleskomat_id): + bleskomat = await get_bleskomat(bleskomat_id) + + if not bleskomat or bleskomat.wallet != g.wallet.id: + return ( + jsonify({"message": "Bleskomat configuration not found."}), + HTTPStatus.NOT_FOUND, + ) + + return jsonify(bleskomat._asdict()), HTTPStatus.OK + + +@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"]) +@bleskomat_ext.route("/api/v1/bleskomat/", methods=["PUT"]) +@api_check_wallet_key("admin") +@api_validate_post_request( + schema={ + "name": {"type": "string", "empty": False, "required": True}, + "fiat_currency": { + "type": "string", + "allowed": fiat_currencies.keys(), + "required": True, + }, + "exchange_rate_provider": { + "type": "string", + "allowed": exchange_rate_providers.keys(), + "required": True, + }, + "fee": {"type": ["string", "float", "number", "integer"], "required": True}, + } +) +async def api_bleskomat_create_or_update(bleskomat_id=None): + try: + fiat_currency = g.data["fiat_currency"] + exchange_rate_provider = g.data["exchange_rate_provider"] + await fetch_fiat_exchange_rate( + currency=fiat_currency, provider=exchange_rate_provider + ) + except Exception as e: + print(e) + return ( + jsonify( + { + "message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"' + } + ), + HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + if bleskomat_id: + bleskomat = await get_bleskomat(bleskomat_id) + if not bleskomat or bleskomat.wallet != g.wallet.id: + return ( + jsonify({"message": "Bleskomat configuration not found."}), + HTTPStatus.NOT_FOUND, + ) + bleskomat = await update_bleskomat(bleskomat_id, **g.data) + else: + bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data) + + return ( + jsonify(bleskomat._asdict()), + HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED, + ) + + +@bleskomat_ext.route("/api/v1/bleskomat/", methods=["DELETE"]) +@api_check_wallet_key("admin") +async def api_bleskomat_delete(bleskomat_id): + bleskomat = await get_bleskomat(bleskomat_id) + + if not bleskomat or bleskomat.wallet != g.wallet.id: + return ( + jsonify({"message": "Bleskomat configuration not found."}), + HTTPStatus.NOT_FOUND, + ) + + await delete_bleskomat(bleskomat_id) + + return "", HTTPStatus.NO_CONTENT From 92ba2db2763039c97416564937a9b2367d7a1375 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Tue, 26 Oct 2021 22:50:29 +0100 Subject: [PATCH 16/27] bleskomat with import error in views_api --- lnbits/extensions/bleskomat/__init__.py | 14 +- lnbits/extensions/bleskomat/crud.py | 19 ++- lnbits/extensions/bleskomat/helpers.py | 13 +- lnbits/extensions/bleskomat/lnurl_api.py | 32 ++--- lnbits/extensions/bleskomat/models.py | 43 +++++- .../extensions/bleskomat/static/js/index.js | 2 +- lnbits/extensions/bleskomat/views.py | 25 ++-- lnbits/extensions/bleskomat/views_api.py | 124 +++++++----------- 8 files changed, 136 insertions(+), 136 deletions(-) diff --git a/lnbits/extensions/bleskomat/__init__.py b/lnbits/extensions/bleskomat/__init__.py index 42f9bb46..094488f4 100644 --- a/lnbits/extensions/bleskomat/__init__.py +++ b/lnbits/extensions/bleskomat/__init__.py @@ -1,12 +1,18 @@ -from quart import Blueprint +from fastapi import APIRouter + from lnbits.db import Database +from lnbits.helpers import template_renderer db = Database("ext_bleskomat") -bleskomat_ext: Blueprint = Blueprint( - "bleskomat", __name__, static_folder="static", template_folder="templates" +bleskomat_ext: APIRouter = APIRouter( + prefix="/bleskomat", + tags=["Bleskomat"] ) +def bleskomat_renderer(): + return template_renderer(["lnbits/extensions/events/templates"]) + from .lnurl_api import * # noqa -from .views_api import * # noqa from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/bleskomat/crud.py b/lnbits/extensions/bleskomat/crud.py index 1cc44576..5efb08e0 100644 --- a/lnbits/extensions/bleskomat/crud.py +++ b/lnbits/extensions/bleskomat/crud.py @@ -1,19 +1,16 @@ import secrets import time -from uuid import uuid4 from typing import List, Optional, Union +from uuid import uuid4 + from . import db -from .models import Bleskomat, BleskomatLnurl from .helpers import generate_bleskomat_lnurl_hash +from .models import Bleskomat, BleskomatLnurl, CreateBleskomat async def create_bleskomat( - *, + data: CreateBleskomat, wallet_id: str, - name: str, - fiat_currency: str, - exchange_rate_provider: str, - fee: str, ) -> Bleskomat: bleskomat_id = uuid4().hex api_key_id = secrets.token_hex(8) @@ -30,10 +27,10 @@ async def create_bleskomat( api_key_id, api_key_secret, api_key_encoding, - name, - fiat_currency, - exchange_rate_provider, - fee, + data.name, + data.fiat_currency, + data.exchange_rate_provider, + data.fee, ), ) bleskomat = await get_bleskomat(bleskomat_id) diff --git a/lnbits/extensions/bleskomat/helpers.py b/lnbits/extensions/bleskomat/helpers.py index a3857b77..3d355048 100644 --- a/lnbits/extensions/bleskomat/helpers.py +++ b/lnbits/extensions/bleskomat/helpers.py @@ -1,11 +1,12 @@ import base64 import hashlib import hmac -from http import HTTPStatus -from binascii import unhexlify -from typing import Dict -from quart import url_for import urllib +from binascii import unhexlify +from http import HTTPStatus +from typing import Dict + +from starlette.requests import Request def generate_bleskomat_lnurl_hash(secret: str): @@ -34,8 +35,8 @@ def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str): return m.hexdigest() -def get_callback_url(): - return url_for("bleskomat.api_bleskomat_lnurl", _external=True) +def get_callback_url(request: Request): + return request.url_for("bleskomat.api_bleskomat_lnurl", _external=True) def is_supported_lnurl_subprotocol(tag: str) -> bool: diff --git a/lnbits/extensions/bleskomat/lnurl_api.py b/lnbits/extensions/bleskomat/lnurl_api.py index 086562d1..fa3e6133 100644 --- a/lnbits/extensions/bleskomat/lnurl_api.py +++ b/lnbits/extensions/bleskomat/lnurl_api.py @@ -1,8 +1,9 @@ import json import math -from quart import jsonify, request -from http import HTTPStatus import traceback +from http import HTTPStatus + +from starlette.requests import Request from . import bleskomat_ext from .crud import ( @@ -10,16 +11,12 @@ from .crud import ( get_bleskomat_by_api_key_id, get_bleskomat_lnurl, ) - -from .exchange_rates import ( - fetch_fiat_exchange_rate, -) - +from .exchange_rates import fetch_fiat_exchange_rate from .helpers import ( - generate_bleskomat_lnurl_signature, - generate_bleskomat_lnurl_secret, LnurlHttpError, LnurlValidationError, + generate_bleskomat_lnurl_secret, + generate_bleskomat_lnurl_signature, prepare_lnurl_params, query_to_signing_payload, unshorten_lnurl_query, @@ -27,10 +24,10 @@ from .helpers import ( # Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs. -@bleskomat_ext.route("/u", methods=["GET"]) -async def api_bleskomat_lnurl(): +@bleskomat_ext.get("/u", name="bleskomat.api_bleskomat_lnurl") +async def api_bleskomat_lnurl(request: Request): try: - query = request.args.to_dict() + query = request.query_params # Unshorten query if "s" is used instead of "signature". if "s" in query: @@ -99,7 +96,7 @@ async def api_bleskomat_lnurl(): ) # Reply with LNURL response object. - return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK + return lnurl.get_info_response_object(secret) # No signature provided. # Treat as "action" callback. @@ -123,12 +120,9 @@ async def api_bleskomat_lnurl(): raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST) except LnurlHttpError as e: - return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status + return {"status": "ERROR", "reason": str(e)} except Exception: traceback.print_exc() - return ( - jsonify({"status": "ERROR", "reason": "Unexpected error"}), - HTTPStatus.INTERNAL_SERVER_ERROR, - ) + return {"status": "ERROR", "reason": "Unexpected error"} - return jsonify({"status": "OK"}), HTTPStatus.OK + return {"status": "OK"} diff --git a/lnbits/extensions/bleskomat/models.py b/lnbits/extensions/bleskomat/models.py index 216f83c6..295e0865 100644 --- a/lnbits/extensions/bleskomat/models.py +++ b/lnbits/extensions/bleskomat/models.py @@ -1,13 +1,44 @@ import json import time -from typing import NamedTuple, Dict +from typing import Dict + +from fastapi.params import Query +from pydantic import BaseModel, validator +from starlette.requests import Request + from lnbits import bolt11 from lnbits.core.services import pay_invoice + from . import db -from .helpers import get_callback_url, LnurlValidationError +from .exchange_rates import exchange_rate_providers, fiat_currencies +from .helpers import LnurlValidationError, get_callback_url -class Bleskomat(NamedTuple): +class CreateBleskomat(BaseModel): + name: str = Query(...) + fiat_currency: str = Query(...) + exchange_rate_provider: str = Query(...) + fee: str = Query(...) + + @validator('fiat_currency') + def allowed_fiat_currencies(cls, v): + if(v not in fiat_currencies.keys()): + raise ValueError('Not allowed currency') + return v + + @validator('exchange_rate_provider') + def allowed_providers(cls, v): + if(v not in exchange_rate_providers.keys()): + raise ValueError('Not allowed provider') + return v + + @validator('fee') + def fee_type(cls, v): + if(not isinstance(v, (str, float, int))): + raise ValueError('Fee type not allowed') + return v + +class Bleskomat(BaseModel): id: str wallet: str api_key_id: str @@ -19,7 +50,7 @@ class Bleskomat(NamedTuple): fee: str -class BleskomatLnurl(NamedTuple): +class BleskomatLnurl(BaseModel): id: str bleskomat: str wallet: str @@ -36,14 +67,14 @@ class BleskomatLnurl(NamedTuple): # When initial uses is 0 then the LNURL has unlimited uses. return self.initial_uses == 0 or self.remaining_uses > 0 - def get_info_response_object(self, secret: str) -> Dict[str, str]: + def get_info_response_object(self, secret: str, req: Request) -> Dict[str, str]: tag = self.tag params = json.loads(self.params) response = {"tag": tag} if tag == "withdrawRequest": for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]: response[key] = params[key] - response["callback"] = get_callback_url() + response["callback"] = get_callback_url(req) response["k1"] = secret return response diff --git a/lnbits/extensions/bleskomat/static/js/index.js b/lnbits/extensions/bleskomat/static/js/index.js index fd166ff3..4e8f993f 100644 --- a/lnbits/extensions/bleskomat/static/js/index.js +++ b/lnbits/extensions/bleskomat/static/js/index.js @@ -84,7 +84,7 @@ new Vue({ LNbits.api .request( 'GET', - '/bleskomat/api/v1/bleskomats?all_wallets', + '/bleskomat/api/v1/bleskomats?all_wallets=True', this.g.user.wallets[0].adminkey ) .then(function (response) { diff --git a/lnbits/extensions/bleskomat/views.py b/lnbits/extensions/bleskomat/views.py index 3a7f7263..7e58f284 100644 --- a/lnbits/extensions/bleskomat/views.py +++ b/lnbits/extensions/bleskomat/views.py @@ -1,22 +1,27 @@ -from quart import g, render_template -from lnbits.decorators import check_user_exists, validate_uuids +from http import HTTPStatus -from . import bleskomat_ext +from fastapi import Request +from fastapi.params import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import bleskomat_ext, bleskomat_renderer from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies from .helpers import get_callback_url -@bleskomat_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): +@bleskomat_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): bleskomat_vars = { - "callback_url": get_callback_url(), + "callback_url": get_callback_url(request), "exchange_rate_providers": exchange_rate_providers_serializable, "fiat_currencies": fiat_currencies, } - return await render_template( - "bleskomat/index.html", user=g.user, bleskomat_vars=bleskomat_vars + return bleskomat_renderer().TemplateResponse( + "bleskomat/index.html", {"request": request, "user": user.dict(), "bleskomat_vars": bleskomat_vars} ) diff --git a/lnbits/extensions/bleskomat/views_api.py b/lnbits/extensions/bleskomat/views_api.py index 2971b066..53062d9a 100644 --- a/lnbits/extensions/bleskomat/views_api.py +++ b/lnbits/extensions/bleskomat/views_api.py @@ -1,120 +1,86 @@ -from quart import g, jsonify, request from http import HTTPStatus +from fastapi.params import Query +from starlette.exceptions import HTTPException + from lnbits.core.crud import get_user -from lnbits.decorators import api_check_wallet_key, api_validate_post_request +from lnbits.decorators import WalletTypeInfo, require_admin_key +from lnbits.extensions.bleskomat.models import CreateBleskomat from . import bleskomat_ext from .crud import ( create_bleskomat, + delete_bleskomat, get_bleskomat, get_bleskomats, update_bleskomat, - delete_bleskomat, -) - -from .exchange_rates import ( - exchange_rate_providers, - fetch_fiat_exchange_rate, - fiat_currencies, ) +from .exchange_rates import fetch_fiat_exchange_rate -@bleskomat_ext.route("/api/v1/bleskomats", methods=["GET"]) -@api_check_wallet_key("admin") -async def api_bleskomats(): - wallet_ids = [g.wallet.id] +@bleskomat_ext.get("/api/v1/bleskomats") +async def api_bleskomats(wallet: WalletTypeInfo(require_admin_key), all_wallets: bool = Query(False)): + wallet_ids = [wallet.wallet.id] - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids + if all_wallets: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids - return ( - jsonify( - [bleskomat._asdict() for bleskomat in await get_bleskomats(wallet_ids)] - ), - HTTPStatus.OK, - ) + return [bleskomat.dict() for bleskomat in await get_bleskomats(wallet_ids)] -@bleskomat_ext.route("/api/v1/bleskomat/", methods=["GET"]) -@api_check_wallet_key("admin") -async def api_bleskomat_retrieve(bleskomat_id): +@bleskomat_ext.get("/api/v1/bleskomat/{bleskomat_id}") +async def api_bleskomat_retrieve(wallet: WalletTypeInfo(require_admin_key), bleskomat_id): bleskomat = await get_bleskomat(bleskomat_id) - if not bleskomat or bleskomat.wallet != g.wallet.id: - return ( - jsonify({"message": "Bleskomat configuration not found."}), - HTTPStatus.NOT_FOUND, - ) + if not bleskomat or bleskomat.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Bleskomat configuration not found." + ) - return jsonify(bleskomat._asdict()), HTTPStatus.OK + return bleskomat.dict() -@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"]) -@bleskomat_ext.route("/api/v1/bleskomat/", methods=["PUT"]) -@api_check_wallet_key("admin") -@api_validate_post_request( - schema={ - "name": {"type": "string", "empty": False, "required": True}, - "fiat_currency": { - "type": "string", - "allowed": fiat_currencies.keys(), - "required": True, - }, - "exchange_rate_provider": { - "type": "string", - "allowed": exchange_rate_providers.keys(), - "required": True, - }, - "fee": {"type": ["string", "float", "number", "integer"], "required": True}, - } -) -async def api_bleskomat_create_or_update(bleskomat_id=None): +@bleskomat_ext.post("/api/v1/bleskomat") +@bleskomat_ext.put("/api/v1/bleskomat/{bleskomat_id}",) +async def api_bleskomat_create_or_update(data: CreateBleskomat, wallet: WalletTypeInfo(require_admin_key), bleskomat_id=None): try: - fiat_currency = g.data["fiat_currency"] - exchange_rate_provider = g.data["exchange_rate_provider"] + fiat_currency = data.fiat_currency + exchange_rate_provider = data.exchange_rate_provider await fetch_fiat_exchange_rate( currency=fiat_currency, provider=exchange_rate_provider ) except Exception as e: print(e) - return ( - jsonify( - { - "message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"' - } - ), - HTTPStatus.INTERNAL_SERVER_ERROR, + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"' ) if bleskomat_id: bleskomat = await get_bleskomat(bleskomat_id) - if not bleskomat or bleskomat.wallet != g.wallet.id: - return ( - jsonify({"message": "Bleskomat configuration not found."}), - HTTPStatus.NOT_FOUND, + if not bleskomat or bleskomat.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Bleskomat configuration not found." ) - bleskomat = await update_bleskomat(bleskomat_id, **g.data) + + bleskomat = await update_bleskomat(bleskomat_id, **data.dict()) else: - bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data) + bleskomat = await create_bleskomat(wallet_id=wallet.wallet.id, data=data) - return ( - jsonify(bleskomat._asdict()), - HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED, - ) + return bleskomat.dict() -@bleskomat_ext.route("/api/v1/bleskomat/", methods=["DELETE"]) -@api_check_wallet_key("admin") -async def api_bleskomat_delete(bleskomat_id): +@bleskomat_ext.delete("/api/v1/bleskomat/{bleskomat_id}") +async def api_bleskomat_delete(wallet: WalletTypeInfo(require_admin_key), bleskomat_id): bleskomat = await get_bleskomat(bleskomat_id) - if not bleskomat or bleskomat.wallet != g.wallet.id: - return ( - jsonify({"message": "Bleskomat configuration not found."}), - HTTPStatus.NOT_FOUND, - ) + if not bleskomat or bleskomat.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Bleskomat configuration not found." + ) await delete_bleskomat(bleskomat_id) - - return "", HTTPStatus.NO_CONTENT + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) From 336e3a833a07ed126f11ef24054e5d9bdb5d8382 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Tue, 26 Oct 2021 22:52:11 +0100 Subject: [PATCH 17/27] paywall initial --- lnbits/extensions/paywall/README.md | 22 ++ lnbits/extensions/paywall/__init__.py | 12 + lnbits/extensions/paywall/config.json | 6 + lnbits/extensions/paywall/crud.py | 53 +++ lnbits/extensions/paywall/migrations.py | 66 ++++ lnbits/extensions/paywall/models.py | 23 ++ .../paywall/templates/paywall/_api_docs.html | 147 +++++++++ .../paywall/templates/paywall/display.html | 162 +++++++++ .../paywall/templates/paywall/index.html | 312 ++++++++++++++++++ lnbits/extensions/paywall/views.py | 22 ++ lnbits/extensions/paywall/views_api.py | 121 +++++++ 11 files changed, 946 insertions(+) create mode 100644 lnbits/extensions/paywall/README.md create mode 100644 lnbits/extensions/paywall/__init__.py create mode 100644 lnbits/extensions/paywall/config.json create mode 100644 lnbits/extensions/paywall/crud.py create mode 100644 lnbits/extensions/paywall/migrations.py create mode 100644 lnbits/extensions/paywall/models.py create mode 100644 lnbits/extensions/paywall/templates/paywall/_api_docs.html create mode 100644 lnbits/extensions/paywall/templates/paywall/display.html create mode 100644 lnbits/extensions/paywall/templates/paywall/index.html create mode 100644 lnbits/extensions/paywall/views.py create mode 100644 lnbits/extensions/paywall/views_api.py diff --git a/lnbits/extensions/paywall/README.md b/lnbits/extensions/paywall/README.md new file mode 100644 index 00000000..738485e2 --- /dev/null +++ b/lnbits/extensions/paywall/README.md @@ -0,0 +1,22 @@ +# Paywall + +## Hide content behind a paywall, a user has to pay some amount to access your hidden content + +A Paywall is a way of restricting to content via a purchase or paid subscription. For example to read a determined blog post, or to continue reading further, to access a downloads area, etc... + +## Usage + +1. Create a paywall by clicking "NEW PAYWALL"\ + ![create new paywall](https://i.imgur.com/q0ZIekC.png) +2. Fill the options for your PAYWALL + - select the wallet + - set the link that will be unlocked after a successful payment + - give your paywall a _Title_ + - an optional small description + - and set an amount a user must pay to access the hidden content. Note this is the minimum amount, a user can over pay if they wish + - if _Remember payments_ is checked, a returning paying user won't have to pay again for the same content.\ + ![paywall config](https://i.imgur.com/CBW48F6.png) +3. You can then use your paywall link to secure your awesome content\ + ![paywall link](https://i.imgur.com/hDQmCDf.png) +4. When a user wants to access your hidden content, he can use the minimum amount or increase and click the "_Check icon_" to generate an invoice, user will then be redirected to the content page\ + ![user paywall view](https://i.imgur.com/3pLywkZ.png) diff --git a/lnbits/extensions/paywall/__init__.py b/lnbits/extensions/paywall/__init__.py new file mode 100644 index 00000000..cf9570a1 --- /dev/null +++ b/lnbits/extensions/paywall/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint +from lnbits.db import Database + +db = Database("ext_paywall") + +paywall_ext: Blueprint = Blueprint( + "paywall", __name__, static_folder="static", template_folder="templates" +) + + +from .views_api import * # noqa +from .views import * # noqa diff --git a/lnbits/extensions/paywall/config.json b/lnbits/extensions/paywall/config.json new file mode 100644 index 00000000..d08ce7ba --- /dev/null +++ b/lnbits/extensions/paywall/config.json @@ -0,0 +1,6 @@ +{ + "name": "Paywall", + "short_description": "Create paywalls for content", + "icon": "policy", + "contributors": ["eillarra"] +} diff --git a/lnbits/extensions/paywall/crud.py b/lnbits/extensions/paywall/crud.py new file mode 100644 index 00000000..c13aba43 --- /dev/null +++ b/lnbits/extensions/paywall/crud.py @@ -0,0 +1,53 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import Paywall + + +async def create_paywall( + *, + wallet_id: str, + url: str, + memo: str, + description: Optional[str] = None, + amount: int = 0, + remembers: bool = True, +) -> Paywall: + paywall_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO paywall.paywalls (id, wallet, url, memo, description, amount, remembers) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (paywall_id, wallet_id, url, memo, description, amount, int(remembers)), + ) + + paywall = await get_paywall(paywall_id) + assert paywall, "Newly created paywall couldn't be retrieved" + return paywall + + +async def get_paywall(paywall_id: str) -> Optional[Paywall]: + row = await db.fetchone( + "SELECT * FROM paywall.paywalls WHERE id = ?", (paywall_id,) + ) + + return Paywall.from_row(row) if row else None + + +async def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM paywall.paywalls WHERE wallet IN ({q})", (*wallet_ids,) + ) + + return [Paywall.from_row(row) for row in rows] + + +async def delete_paywall(paywall_id: str) -> None: + await db.execute("DELETE FROM paywall.paywalls WHERE id = ?", (paywall_id,)) diff --git a/lnbits/extensions/paywall/migrations.py b/lnbits/extensions/paywall/migrations.py new file mode 100644 index 00000000..8afe58b1 --- /dev/null +++ b/lnbits/extensions/paywall/migrations.py @@ -0,0 +1,66 @@ +from sqlalchemy.exc import OperationalError # type: ignore + + +async def m001_initial(db): + """ + Initial paywalls table. + """ + await db.execute( + """ + CREATE TABLE paywall.paywalls ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + secret TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + amount INTEGER NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + +async def m002_redux(db): + """ + Creates an improved paywalls table and migrates the existing data. + """ + await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old") + await db.execute( + """ + CREATE TABLE paywall.paywalls ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + url TEXT NOT NULL, + memo TEXT NOT NULL, + description TEXT NULL, + amount INTEGER DEFAULT 0, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """, + remembers INTEGER DEFAULT 0, + extras TEXT NULL + ); + """ + ) + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM paywall.paywalls_old") + ]: + await db.execute( + """ + INSERT INTO paywall.paywalls ( + id, + wallet, + url, + memo, + amount, + time + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (row[0], row[1], row[3], row[4], row[5], row[6]), + ) + + await db.execute("DROP TABLE paywall.paywalls_old") diff --git a/lnbits/extensions/paywall/models.py b/lnbits/extensions/paywall/models.py new file mode 100644 index 00000000..d7f2451d --- /dev/null +++ b/lnbits/extensions/paywall/models.py @@ -0,0 +1,23 @@ +import json + +from sqlite3 import Row +from typing import NamedTuple, Optional + + +class Paywall(NamedTuple): + id: str + wallet: str + url: str + memo: str + description: str + amount: int + time: int + remembers: bool + extras: Optional[dict] + + @classmethod + def from_row(cls, row: Row) -> "Paywall": + data = dict(row) + data["remembers"] = bool(data["remembers"]) + data["extras"] = json.loads(data["extras"]) if data["extras"] else None + return cls(**data) diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html new file mode 100644 index 00000000..1157fa46 --- /dev/null +++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html @@ -0,0 +1,147 @@ + + + + + GET /paywall/api/v1/paywalls +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<paywall_object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{ + g.user.wallets[0].inkey }}" + +
+
+
+ + + + POST /paywall/api/v1/paywalls +
Headers
+ {"X-Api-Key": <admin_key>}
+
Body (application/json)
+ {"amount": <integer>, "description": <string>, "memo": + <string>, "remembers": <boolean>, "url": + <string>} +
+ Returns 201 CREATED (application/json) +
+ {"amount": <integer>, "description": <string>, "id": + <string>, "memo": <string>, "remembers": <boolean>, + "time": <int>, "url": <string>, "wallet": + <string>} +
Curl example
+ curl -X POST {{ request.url_root }}api/v1/paywalls -d '{"url": + <string>, "memo": <string>, "description": <string>, + "amount": <integer>, "remembers": <boolean>}' -H + "Content-type: application/json" -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+ + + + POST + /paywall/api/v1/paywalls/<paywall_id>/invoice +
Body (application/json)
+ {"amount": <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"payment_hash": <string>, "payment_request": + <string>} +
Curl example
+ curl -X POST {{ request.url_root + }}api/v1/paywalls/<paywall_id>/invoice -d '{"amount": + <integer>}' -H "Content-type: application/json" + +
+
+
+ + + + POST + /paywall/api/v1/paywalls/<paywall_id>/check_invoice +
Body (application/json)
+ {"payment_hash": <string>} +
+ Returns 200 OK (application/json) +
+ {"paid": false}
+ {"paid": true, "url": <string>, "remembers": + <boolean>} +
Curl example
+ curl -X POST {{ request.url_root + }}api/v1/paywalls/<paywall_id>/check_invoice -d + '{"payment_hash": <string>}' -H "Content-type: application/json" + +
+
+
+ + + + DELETE + /paywall/api/v1/paywalls/<paywall_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.url_root + }}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{ + g.user.wallets[0].adminkey }}" + +
+
+
+
diff --git a/lnbits/extensions/paywall/templates/paywall/display.html b/lnbits/extensions/paywall/templates/paywall/display.html new file mode 100644 index 00000000..7bc7d9b8 --- /dev/null +++ b/lnbits/extensions/paywall/templates/paywall/display.html @@ -0,0 +1,162 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
{{ paywall.memo }}
+ {% if paywall.description %} +

{{ paywall.description }}

+ {% endif %} +
+ + + + + +
+ + + + + +
+ Copy invoice + Cancel +
+
+
+
+ +

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

+
+ Open URL +
+
+
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/paywall/templates/paywall/index.html b/lnbits/extensions/paywall/templates/paywall/index.html new file mode 100644 index 00000000..8be3b2fa --- /dev/null +++ b/lnbits/extensions/paywall/templates/paywall/index.html @@ -0,0 +1,312 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New paywall + + + + + +
+
+
Paywalls
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+ +
+ + +
+ {{SITE_TITLE}} paywall extension +
+
+ + + {% include "paywall/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + + Remember payments + A succesful payment will be registered in the browser's + storage, so the user doesn't need to pay again to access the + URL. + + + +
+ Create paywall + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/paywall/views.py b/lnbits/extensions/paywall/views.py new file mode 100644 index 00000000..0dcbad2f --- /dev/null +++ b/lnbits/extensions/paywall/views.py @@ -0,0 +1,22 @@ +from quart import g, abort, render_template +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids + +from . import paywall_ext +from .crud import get_paywall + + +@paywall_ext.route("/") +@validate_uuids(["usr"], required=True) +@check_user_exists() +async def index(): + return await render_template("paywall/index.html", user=g.user) + + +@paywall_ext.route("/") +async def display(paywall_id): + paywall = await get_paywall(paywall_id) or abort( + HTTPStatus.NOT_FOUND, "Paywall does not exist." + ) + return await render_template("paywall/display.html", paywall=paywall) diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py new file mode 100644 index 00000000..45c80af4 --- /dev/null +++ b/lnbits/extensions/paywall/views_api.py @@ -0,0 +1,121 @@ +from quart import g, jsonify, request +from http import HTTPStatus + +from lnbits.core.crud import get_user, get_wallet +from lnbits.core.services import create_invoice, check_invoice_status +from lnbits.decorators import api_check_wallet_key, api_validate_post_request + +from . import paywall_ext +from .crud import create_paywall, get_paywall, get_paywalls, delete_paywall + + +@paywall_ext.route("/api/v1/paywalls", methods=["GET"]) +@api_check_wallet_key("invoice") +async def api_paywalls(): + wallet_ids = [g.wallet.id] + + if "all_wallets" in request.args: + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + + return ( + jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]), + HTTPStatus.OK, + ) + + +@paywall_ext.route("/api/v1/paywalls", methods=["POST"]) +@api_check_wallet_key("invoice") +@api_validate_post_request( + schema={ + "url": {"type": "string", "empty": False, "required": True}, + "memo": {"type": "string", "empty": False, "required": True}, + "description": { + "type": "string", + "empty": True, + "nullable": True, + "required": False, + }, + "amount": {"type": "integer", "min": 0, "required": True}, + "remembers": {"type": "boolean", "required": True}, + } +) +async def api_paywall_create(): + paywall = await create_paywall(wallet_id=g.wallet.id, **g.data) + return jsonify(paywall._asdict()), HTTPStatus.CREATED + + +@paywall_ext.route("/api/v1/paywalls/", methods=["DELETE"]) +@api_check_wallet_key("invoice") +async def api_paywall_delete(paywall_id): + paywall = await get_paywall(paywall_id) + + if not paywall: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + if paywall.wallet != g.wallet.id: + return jsonify({"message": "Not your paywall."}), HTTPStatus.FORBIDDEN + + await delete_paywall(paywall_id) + + return "", HTTPStatus.NO_CONTENT + + +@paywall_ext.route("/api/v1/paywalls//invoice", methods=["POST"]) +@api_validate_post_request( + schema={"amount": {"type": "integer", "min": 1, "required": True}} +) +async def api_paywall_create_invoice(paywall_id): + paywall = await get_paywall(paywall_id) + + if g.data["amount"] < paywall.amount: + return ( + jsonify({"message": f"Minimum amount is {paywall.amount} sat."}), + HTTPStatus.BAD_REQUEST, + ) + + try: + amount = ( + g.data["amount"] if g.data["amount"] > paywall.amount else paywall.amount + ) + payment_hash, payment_request = await create_invoice( + wallet_id=paywall.wallet, + amount=amount, + memo=f"{paywall.memo}", + extra={"tag": "paywall"}, + ) + except Exception as e: + return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + + return ( + jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), + HTTPStatus.CREATED, + ) + + +@paywall_ext.route("/api/v1/paywalls//check_invoice", methods=["POST"]) +@api_validate_post_request( + schema={"payment_hash": {"type": "string", "empty": False, "required": True}} +) +async def api_paywal_check_invoice(paywall_id): + paywall = await get_paywall(paywall_id) + + if not paywall: + return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + + try: + status = await check_invoice_status(paywall.wallet, g.data["payment_hash"]) + is_paid = not status.pending + except Exception: + return jsonify({"paid": False}), HTTPStatus.OK + + if is_paid: + wallet = await get_wallet(paywall.wallet) + payment = await wallet.get_payment(g.data["payment_hash"]) + await payment.set_pending(False) + + return ( + jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}), + HTTPStatus.OK, + ) + + return jsonify({"paid": False}), HTTPStatus.OK From 1653c45f12ab01138ec1d13e67ee8f118f781e11 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 27 Oct 2021 12:01:45 +0100 Subject: [PATCH 18/27] fix bleskomat --- lnbits/extensions/bleskomat/__init__.py | 11 ++++++++++- lnbits/extensions/bleskomat/helpers.py | 2 +- lnbits/extensions/bleskomat/static/js/index.js | 2 +- lnbits/extensions/bleskomat/views.py | 7 +++---- lnbits/extensions/bleskomat/views_api.py | 10 +++++----- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/lnbits/extensions/bleskomat/__init__.py b/lnbits/extensions/bleskomat/__init__.py index 094488f4..bb967859 100644 --- a/lnbits/extensions/bleskomat/__init__.py +++ b/lnbits/extensions/bleskomat/__init__.py @@ -1,17 +1,26 @@ from fastapi import APIRouter +from starlette.staticfiles import StaticFiles from lnbits.db import Database from lnbits.helpers import template_renderer db = Database("ext_bleskomat") +bleskomat_static_files = [ + { + "path": "/bleskomat/static", + "app": StaticFiles(directory="lnbits/extensions/bleskomat/static"), + "name": "bleskomat_static", + } +] + bleskomat_ext: APIRouter = APIRouter( prefix="/bleskomat", tags=["Bleskomat"] ) def bleskomat_renderer(): - return template_renderer(["lnbits/extensions/events/templates"]) + return template_renderer(["lnbits/extensions/bleskomat/templates"]) from .lnurl_api import * # noqa from .views import * # noqa diff --git a/lnbits/extensions/bleskomat/helpers.py b/lnbits/extensions/bleskomat/helpers.py index 3d355048..1062ca27 100644 --- a/lnbits/extensions/bleskomat/helpers.py +++ b/lnbits/extensions/bleskomat/helpers.py @@ -36,7 +36,7 @@ def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str): def get_callback_url(request: Request): - return request.url_for("bleskomat.api_bleskomat_lnurl", _external=True) + return request.url_for("bleskomat.api_bleskomat_lnurl") def is_supported_lnurl_subprotocol(tag: str) -> bool: diff --git a/lnbits/extensions/bleskomat/static/js/index.js b/lnbits/extensions/bleskomat/static/js/index.js index 4e8f993f..f20a4659 100644 --- a/lnbits/extensions/bleskomat/static/js/index.js +++ b/lnbits/extensions/bleskomat/static/js/index.js @@ -84,7 +84,7 @@ new Vue({ LNbits.api .request( 'GET', - '/bleskomat/api/v1/bleskomats?all_wallets=True', + '/bleskomat/api/v1/bleskomats?all_wallets=true', this.g.user.wallets[0].adminkey ) .then(function (response) { diff --git a/lnbits/extensions/bleskomat/views.py b/lnbits/extensions/bleskomat/views.py index 7e58f284..8467385e 100644 --- a/lnbits/extensions/bleskomat/views.py +++ b/lnbits/extensions/bleskomat/views.py @@ -1,10 +1,7 @@ -from http import HTTPStatus - from fastapi import Request from fastapi.params import Depends from fastapi.templating import Jinja2Templates -from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse from lnbits.core.models import User @@ -14,11 +11,13 @@ from . import bleskomat_ext, bleskomat_renderer from .exchange_rates import exchange_rate_providers_serializable, fiat_currencies from .helpers import get_callback_url +templates = Jinja2Templates(directory="templates") + @bleskomat_ext.get("/", response_class=HTMLResponse) async def index(request: Request, user: User = Depends(check_user_exists)): bleskomat_vars = { - "callback_url": get_callback_url(request), + "callback_url": get_callback_url(request=request), "exchange_rate_providers": exchange_rate_providers_serializable, "fiat_currencies": fiat_currencies, } diff --git a/lnbits/extensions/bleskomat/views_api.py b/lnbits/extensions/bleskomat/views_api.py index 53062d9a..d8960429 100644 --- a/lnbits/extensions/bleskomat/views_api.py +++ b/lnbits/extensions/bleskomat/views_api.py @@ -1,6 +1,6 @@ from http import HTTPStatus -from fastapi.params import Query +from fastapi import Depends, Query from starlette.exceptions import HTTPException from lnbits.core.crud import get_user @@ -19,7 +19,7 @@ from .exchange_rates import fetch_fiat_exchange_rate @bleskomat_ext.get("/api/v1/bleskomats") -async def api_bleskomats(wallet: WalletTypeInfo(require_admin_key), all_wallets: bool = Query(False)): +async def api_bleskomats(wallet: WalletTypeInfo = Depends(require_admin_key), all_wallets: bool = Query(False)): wallet_ids = [wallet.wallet.id] if all_wallets: @@ -29,7 +29,7 @@ async def api_bleskomats(wallet: WalletTypeInfo(require_admin_key), all_wallets: @bleskomat_ext.get("/api/v1/bleskomat/{bleskomat_id}") -async def api_bleskomat_retrieve(wallet: WalletTypeInfo(require_admin_key), bleskomat_id): +async def api_bleskomat_retrieve(bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)): bleskomat = await get_bleskomat(bleskomat_id) if not bleskomat or bleskomat.wallet != wallet.wallet.id: @@ -43,7 +43,7 @@ async def api_bleskomat_retrieve(wallet: WalletTypeInfo(require_admin_key), bles @bleskomat_ext.post("/api/v1/bleskomat") @bleskomat_ext.put("/api/v1/bleskomat/{bleskomat_id}",) -async def api_bleskomat_create_or_update(data: CreateBleskomat, wallet: WalletTypeInfo(require_admin_key), bleskomat_id=None): +async def api_bleskomat_create_or_update(data: CreateBleskomat, wallet: WalletTypeInfo = Depends(require_admin_key), bleskomat_id=None): try: fiat_currency = data.fiat_currency exchange_rate_provider = data.exchange_rate_provider @@ -73,7 +73,7 @@ async def api_bleskomat_create_or_update(data: CreateBleskomat, wallet: WalletTy @bleskomat_ext.delete("/api/v1/bleskomat/{bleskomat_id}") -async def api_bleskomat_delete(wallet: WalletTypeInfo(require_admin_key), bleskomat_id): +async def api_bleskomat_delete(bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)): bleskomat = await get_bleskomat(bleskomat_id) if not bleskomat or bleskomat.wallet != wallet.wallet.id: From 1021d3c81c317b953f676d2f21063c0cc4e3f4bf Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 27 Oct 2021 12:02:27 +0100 Subject: [PATCH 19/27] paywall converted but create invoice creates BAD REQUEST --- lnbits/extensions/paywall/__init__.py | 14 +- lnbits/extensions/paywall/crud.py | 12 +- lnbits/extensions/paywall/models.py | 23 +++- .../paywall/templates/paywall/_api_docs.html | 6 +- .../paywall/templates/paywall/display.html | 4 +- .../paywall/templates/paywall/index.html | 2 +- lnbits/extensions/paywall/views.py | 32 +++-- lnbits/extensions/paywall/views_api.py | 130 ++++++++---------- 8 files changed, 113 insertions(+), 110 deletions(-) diff --git a/lnbits/extensions/paywall/__init__.py b/lnbits/extensions/paywall/__init__.py index cf9570a1..bdc05620 100644 --- a/lnbits/extensions/paywall/__init__.py +++ b/lnbits/extensions/paywall/__init__.py @@ -1,12 +1,18 @@ -from quart import Blueprint +from fastapi import APIRouter + from lnbits.db import Database +from lnbits.helpers import template_renderer db = Database("ext_paywall") -paywall_ext: Blueprint = Blueprint( - "paywall", __name__, static_folder="static", template_folder="templates" +paywall_ext: APIRouter = APIRouter( + prefix="/paywall", + tags=["Paywall"] ) +def paywall_renderer(): + return template_renderer(["lnbits/extensions/paywall/templates"]) + -from .views_api import * # noqa from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/paywall/crud.py b/lnbits/extensions/paywall/crud.py index c13aba43..1f888167 100644 --- a/lnbits/extensions/paywall/crud.py +++ b/lnbits/extensions/paywall/crud.py @@ -3,17 +3,12 @@ from typing import List, Optional, Union from lnbits.helpers import urlsafe_short_hash from . import db -from .models import Paywall +from .models import CreatePaywall, Paywall async def create_paywall( - *, wallet_id: str, - url: str, - memo: str, - description: Optional[str] = None, - amount: int = 0, - remembers: bool = True, + data: CreatePaywall ) -> Paywall: paywall_id = urlsafe_short_hash() await db.execute( @@ -21,7 +16,7 @@ async def create_paywall( INSERT INTO paywall.paywalls (id, wallet, url, memo, description, amount, remembers) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (paywall_id, wallet_id, url, memo, description, amount, int(remembers)), + (paywall_id, wallet_id, data.url, data.memo, data.description, data.amount, int(data.remembers)), ) paywall = await get_paywall(paywall_id) @@ -33,7 +28,6 @@ async def get_paywall(paywall_id: str) -> Optional[Paywall]: row = await db.fetchone( "SELECT * FROM paywall.paywalls WHERE id = ?", (paywall_id,) ) - return Paywall.from_row(row) if row else None diff --git a/lnbits/extensions/paywall/models.py b/lnbits/extensions/paywall/models.py index d7f2451d..c150372d 100644 --- a/lnbits/extensions/paywall/models.py +++ b/lnbits/extensions/paywall/models.py @@ -1,15 +1,30 @@ import json - from sqlite3 import Row -from typing import NamedTuple, Optional +from typing import Optional + +from fastapi import Query +from pydantic import BaseModel -class Paywall(NamedTuple): +class CreatePaywall(BaseModel): + url: str = Query(...) + memo: str = Query(...) + description: str = Query(None) + amount: int = Query(..., ge=0) + remembers: bool = Query(...) + +class CreatePaywallInvoice(BaseModel): + amount: int = Query(..., ge=1) + +class CheckPaywallInvoice(BaseModel): + payment_hash: str = Query(...) + +class Paywall(BaseModel): id: str wallet: str url: str memo: str - description: str + description: Optional[str] amount: int time: int remembers: bool diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html index 1157fa46..ceadf2f0 100644 --- a/lnbits/extensions/paywall/templates/paywall/_api_docs.html +++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html @@ -18,7 +18,7 @@
Curl example
curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" + user.wallets[0].inkey }}" @@ -52,7 +52,7 @@ <string>, "memo": <string>, "description": <string>, "amount": <integer>, "remembers": <boolean>}' -H "Content-type: application/json" -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" + user.wallets[0].adminkey }}" @@ -139,7 +139,7 @@ curl -X DELETE {{ request.url_root }}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{ - g.user.wallets[0].adminkey }}" + user.wallets[0].adminkey }}" diff --git a/lnbits/extensions/paywall/templates/paywall/display.html b/lnbits/extensions/paywall/templates/paywall/display.html index 7bc7d9b8..394578b3 100644 --- a/lnbits/extensions/paywall/templates/paywall/display.html +++ b/lnbits/extensions/paywall/templates/paywall/display.html @@ -102,11 +102,11 @@ }, createInvoice: function () { var self = this - + console.log(this.amount) axios .post( '/paywall/api/v1/paywalls/{{ paywall.id }}/invoice', - {amount: this.amount} + {amount: self.amount} ) .then(function (response) { self.paymentReq = response.data.payment_request.toUpperCase() diff --git a/lnbits/extensions/paywall/templates/paywall/index.html b/lnbits/extensions/paywall/templates/paywall/index.html index 8be3b2fa..482d1465 100644 --- a/lnbits/extensions/paywall/templates/paywall/index.html +++ b/lnbits/extensions/paywall/templates/paywall/index.html @@ -237,7 +237,7 @@ LNbits.api .request( 'GET', - '/paywall/api/v1/paywalls?all_wallets', + '/paywall/api/v1/paywalls?all_wallets=true', this.g.user.wallets[0].inkey ) .then(function (response) { diff --git a/lnbits/extensions/paywall/views.py b/lnbits/extensions/paywall/views.py index 0dcbad2f..e07596c4 100644 --- a/lnbits/extensions/paywall/views.py +++ b/lnbits/extensions/paywall/views.py @@ -1,22 +1,26 @@ -from quart import g, abort, render_template from http import HTTPStatus -from lnbits.decorators import check_user_exists, validate_uuids +from fastapi import Depends +from starlette.exceptions import HTTPException +from starlette.requests import Request -from . import paywall_ext +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import paywall_ext, paywall_renderer from .crud import get_paywall -@paywall_ext.route("/") -@validate_uuids(["usr"], required=True) -@check_user_exists() -async def index(): - return await render_template("paywall/index.html", user=g.user) +@paywall_ext.get("/") +async def index(request: Request, user: User = Depends(check_user_exists)): + return paywall_renderer().TemplateResponse("paywall/index.html", {"request": request, "user": user.dict()}) -@paywall_ext.route("/") -async def display(paywall_id): - paywall = await get_paywall(paywall_id) or abort( - HTTPStatus.NOT_FOUND, "Paywall does not exist." - ) - return await render_template("paywall/display.html", paywall=paywall) +@paywall_ext.get("/{paywall_id}") +async def display(request: Request, paywall_id): + paywall = await get_paywall(paywall_id) + if not paywall: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist." + ) + return paywall_renderer().TemplateResponse("paywall/display.html", {"request": request, "paywall": paywall}) diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py index 45c80af4..38f8d0b6 100644 --- a/lnbits/extensions/paywall/views_api.py +++ b/lnbits/extensions/paywall/views_api.py @@ -1,81 +1,68 @@ -from quart import g, jsonify, request from http import HTTPStatus +from fastapi import Depends, Query +from starlette.exceptions import HTTPException + from lnbits.core.crud import get_user, get_wallet -from lnbits.core.services import create_invoice, check_invoice_status -from lnbits.decorators import api_check_wallet_key, api_validate_post_request +from lnbits.core.services import check_invoice_status, create_invoice +from lnbits.decorators import WalletTypeInfo, get_key_type from . import paywall_ext -from .crud import create_paywall, get_paywall, get_paywalls, delete_paywall +from .crud import create_paywall, delete_paywall, get_paywall, get_paywalls +from .models import CheckPaywallInvoice, CreatePaywall, CreatePaywallInvoice -@paywall_ext.route("/api/v1/paywalls", methods=["GET"]) -@api_check_wallet_key("invoice") -async def api_paywalls(): - wallet_ids = [g.wallet.id] +@paywall_ext.get("/api/v1/paywalls") +async def api_paywalls(wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)): + wallet_ids = [wallet.wallet.id] - if "all_wallets" in request.args: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids + if all_wallets: + wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids - return ( - jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]), - HTTPStatus.OK, - ) + return [paywall.dict() for paywall in await get_paywalls(wallet_ids)] -@paywall_ext.route("/api/v1/paywalls", methods=["POST"]) -@api_check_wallet_key("invoice") -@api_validate_post_request( - schema={ - "url": {"type": "string", "empty": False, "required": True}, - "memo": {"type": "string", "empty": False, "required": True}, - "description": { - "type": "string", - "empty": True, - "nullable": True, - "required": False, - }, - "amount": {"type": "integer", "min": 0, "required": True}, - "remembers": {"type": "boolean", "required": True}, - } -) -async def api_paywall_create(): - paywall = await create_paywall(wallet_id=g.wallet.id, **g.data) - return jsonify(paywall._asdict()), HTTPStatus.CREATED +@paywall_ext.post("/api/v1/paywalls") +async def api_paywall_create(data: CreatePaywall, wallet: WalletTypeInfo = Depends(get_key_type)): + paywall = await create_paywall(wallet_id=wallet.wallet.id, data=data) + return paywall.dict() -@paywall_ext.route("/api/v1/paywalls/", methods=["DELETE"]) -@api_check_wallet_key("invoice") -async def api_paywall_delete(paywall_id): +@paywall_ext.delete("/api/v1/paywalls/{paywall_id}") +async def api_paywall_delete(paywall_id, wallet: WalletTypeInfo = Depends(get_key_type)): paywall = await get_paywall(paywall_id) if not paywall: - return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Paywall does not exist." + ) - if paywall.wallet != g.wallet.id: - return jsonify({"message": "Not your paywall."}), HTTPStatus.FORBIDDEN + if paywall.wallet != wallet.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not your paywall." + ) await delete_paywall(paywall_id) - - return "", HTTPStatus.NO_CONTENT + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) -@paywall_ext.route("/api/v1/paywalls//invoice", methods=["POST"]) -@api_validate_post_request( - schema={"amount": {"type": "integer", "min": 1, "required": True}} -) -async def api_paywall_create_invoice(paywall_id): +@paywall_ext.post("/api/v1/paywalls/{paywall_id}/invoice") +async def api_paywall_create_invoice(paywall_id, data: CreatePaywallInvoice, wallet: WalletTypeInfo = Depends(get_key_type)): paywall = await get_paywall(paywall_id) - - if g.data["amount"] < paywall.amount: - return ( - jsonify({"message": f"Minimum amount is {paywall.amount} sat."}), - HTTPStatus.BAD_REQUEST, - ) + print("PAYW", paywall) + print("DATA", data) + + if data.amount < paywall.amount: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Minimum amount is {paywall.amount} sat." + ) try: amount = ( - g.data["amount"] if g.data["amount"] > paywall.amount else paywall.amount + data.amount if data.amount > paywall.amount else paywall.amount ) payment_hash, payment_request = await create_invoice( wallet_id=paywall.wallet, @@ -84,38 +71,35 @@ async def api_paywall_create_invoice(paywall_id): extra={"tag": "paywall"}, ) except Exception as e: - return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail=str(e) + ) - return ( - jsonify({"payment_hash": payment_hash, "payment_request": payment_request}), - HTTPStatus.CREATED, - ) + return {"payment_hash": payment_hash, "payment_request": payment_request} -@paywall_ext.route("/api/v1/paywalls//check_invoice", methods=["POST"]) -@api_validate_post_request( - schema={"payment_hash": {"type": "string", "empty": False, "required": True}} -) -async def api_paywal_check_invoice(paywall_id): +@paywall_ext.post("/api/v1/paywalls/{paywall_id}/check_invoice") +async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id): paywall = await get_paywall(paywall_id) - + payment_hash = data.payment_hash if not paywall: - return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Paywall does not exist." + ) try: - status = await check_invoice_status(paywall.wallet, g.data["payment_hash"]) + status = await check_invoice_status(paywall.wallet, payment_hash) is_paid = not status.pending except Exception: - return jsonify({"paid": False}), HTTPStatus.OK + return {"paid": False} if is_paid: wallet = await get_wallet(paywall.wallet) - payment = await wallet.get_payment(g.data["payment_hash"]) + payment = await wallet.get_payment(payment_hash) await payment.set_pending(False) - return ( - jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}), - HTTPStatus.OK, - ) + return {"paid": True, "url": paywall.url, "remembers": paywall.remembers} - return jsonify({"paid": False}), HTTPStatus.OK + return {"paid": False} From cf44dc084122e27238e706c89bb764fbdab13b8d Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Wed, 27 Oct 2021 22:09:55 +0100 Subject: [PATCH 20/27] subdomains untested convert --- lnbits/extensions/subdomains/README.md | 54 ++ lnbits/extensions/subdomains/__init__.py | 28 + lnbits/extensions/subdomains/cloudflare.py | 60 ++ lnbits/extensions/subdomains/config.json | 6 + lnbits/extensions/subdomains/crud.py | 168 ++++++ lnbits/extensions/subdomains/migrations.py | 41 ++ lnbits/extensions/subdomains/models.py | 49 ++ lnbits/extensions/subdomains/tasks.py | 67 +++ .../templates/subdomains/_api_docs.html | 26 + .../templates/subdomains/display.html | 221 +++++++ .../templates/subdomains/index.html | 550 ++++++++++++++++++ lnbits/extensions/subdomains/util.py | 36 ++ lnbits/extensions/subdomains/views.py | 43 ++ lnbits/extensions/subdomains/views_api.py | 195 +++++++ 14 files changed, 1544 insertions(+) create mode 100644 lnbits/extensions/subdomains/README.md create mode 100644 lnbits/extensions/subdomains/__init__.py create mode 100644 lnbits/extensions/subdomains/cloudflare.py create mode 100644 lnbits/extensions/subdomains/config.json create mode 100644 lnbits/extensions/subdomains/crud.py create mode 100644 lnbits/extensions/subdomains/migrations.py create mode 100644 lnbits/extensions/subdomains/models.py create mode 100644 lnbits/extensions/subdomains/tasks.py create mode 100644 lnbits/extensions/subdomains/templates/subdomains/_api_docs.html create mode 100644 lnbits/extensions/subdomains/templates/subdomains/display.html create mode 100644 lnbits/extensions/subdomains/templates/subdomains/index.html create mode 100644 lnbits/extensions/subdomains/util.py create mode 100644 lnbits/extensions/subdomains/views.py create mode 100644 lnbits/extensions/subdomains/views_api.py diff --git a/lnbits/extensions/subdomains/README.md b/lnbits/extensions/subdomains/README.md new file mode 100644 index 00000000..729f40f4 --- /dev/null +++ b/lnbits/extensions/subdomains/README.md @@ -0,0 +1,54 @@ +

Subdomains Extension

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

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

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

{{ domain_domain }}

+
+
{{ domain_desc }}
+
+ + + + + + + + + +

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

+
+ Submit + Cancel +
+
+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/subdomains/templates/subdomains/index.html b/lnbits/extensions/subdomains/templates/subdomains/index.html new file mode 100644 index 00000000..06c80d35 --- /dev/null +++ b/lnbits/extensions/subdomains/templates/subdomains/index.html @@ -0,0 +1,550 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} + +
+
+ + + New Domain + + + + + +
+
+
Domains
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Subdomains
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Subdomain extension +
+
+ + + {% include "subdomains/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + + + +
+ Update Form + Create Domain + Cancel +
+
+
+
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/subdomains/util.py b/lnbits/extensions/subdomains/util.py new file mode 100644 index 00000000..c7d66307 --- /dev/null +++ b/lnbits/extensions/subdomains/util.py @@ -0,0 +1,36 @@ +from lnbits.extensions.subdomains.models import Subdomains + +# Python3 program to validate +# domain name +# using regular expression +import re +import socket + +# Function to validate domain name. +def isValidDomain(str): + # Regex to check valid + # domain name. + regex = "^((?!-)[A-Za-z0-9-]{1,63}(? Date: Wed, 27 Oct 2021 22:17:27 +0100 Subject: [PATCH 21/27] fix not fetching domains --- lnbits/extensions/subdomains/templates/subdomains/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lnbits/extensions/subdomains/templates/subdomains/index.html b/lnbits/extensions/subdomains/templates/subdomains/index.html index 06c80d35..55a70280 100644 --- a/lnbits/extensions/subdomains/templates/subdomains/index.html +++ b/lnbits/extensions/subdomains/templates/subdomains/index.html @@ -440,7 +440,7 @@ LNbits.api .request( 'GET', - '/subdomains/api/v1/domains?all_wallets', + '/subdomains/api/v1/domains?all_wallets=true', this.g.user.wallets[0].inkey ) .then(function (response) { From ff1e8aff10867900d283f9947bdff810ba823ada Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 28 Oct 2021 10:55:22 +0100 Subject: [PATCH 22/27] hivemind converted --- lnbits/extensions/hivemind/README.md | 3 ++ lnbits/extensions/hivemind/__init__.py | 17 +++++++++ lnbits/extensions/hivemind/config.json | 6 ++++ lnbits/extensions/hivemind/migrations.py | 10 ++++++ lnbits/extensions/hivemind/models.py | 11 ++++++ .../hivemind/templates/hivemind/index.html | 35 +++++++++++++++++++ lnbits/extensions/hivemind/views.py | 13 +++++++ 7 files changed, 95 insertions(+) create mode 100644 lnbits/extensions/hivemind/README.md create mode 100644 lnbits/extensions/hivemind/__init__.py create mode 100644 lnbits/extensions/hivemind/config.json create mode 100644 lnbits/extensions/hivemind/migrations.py create mode 100644 lnbits/extensions/hivemind/models.py create mode 100644 lnbits/extensions/hivemind/templates/hivemind/index.html create mode 100644 lnbits/extensions/hivemind/views.py diff --git a/lnbits/extensions/hivemind/README.md b/lnbits/extensions/hivemind/README.md new file mode 100644 index 00000000..1e9667ec --- /dev/null +++ b/lnbits/extensions/hivemind/README.md @@ -0,0 +1,3 @@ +

Hivemind

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

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

+

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

+

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

+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/hivemind/views.py b/lnbits/extensions/hivemind/views.py new file mode 100644 index 00000000..51122dfd --- /dev/null +++ b/lnbits/extensions/hivemind/views.py @@ -0,0 +1,13 @@ +from fastapi.param_functions import Depends +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import hivemind_ext, hivemind_renderer + + +@hivemind_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return hivemind_renderer().TemplateResponse("hivemind/index.html", {"request": request, "user": user.dict()}) From f15b6b128b09381651bbd1e285144d5f0ccaa378 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 28 Oct 2021 17:01:49 +0100 Subject: [PATCH 23/27] clean up --- lnbits/extensions/copilot/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lnbits/extensions/copilot/__init__.py b/lnbits/extensions/copilot/__init__.py index 4252eddc..8a634267 100644 --- a/lnbits/extensions/copilot/__init__.py +++ b/lnbits/extensions/copilot/__init__.py @@ -1,7 +1,8 @@ import asyncio -from fastapi import APIRouter, FastAPI + +from fastapi import APIRouter from fastapi.staticfiles import StaticFiles -from starlette.routing import Mount + from lnbits.db import Database from lnbits.helpers import template_renderer from lnbits.tasks import catch_everything_and_restart @@ -22,10 +23,10 @@ def copilot_renderer(): return template_renderer(["lnbits/extensions/copilot/templates"]) -from .views_api import * # noqa -from .views import * # noqa -from .tasks import wait_for_paid_invoices from .lnurl import * # noqa +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa def copilot_start(): From 6278e5357cf2f53d8ffa3b1c0ca42c48c6fffd6c Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Thu, 28 Oct 2021 17:02:07 +0100 Subject: [PATCH 24/27] livestreams converted --- lnbits/extensions/livestream/README.md | 45 +++ lnbits/extensions/livestream/__init__.py | 36 ++ lnbits/extensions/livestream/config.json | 10 + lnbits/extensions/livestream/crud.py | 200 +++++++++++ lnbits/extensions/livestream/lnurl.py | 120 +++++++ lnbits/extensions/livestream/migrations.py | 39 +++ lnbits/extensions/livestream/models.py | 89 +++++ .../extensions/livestream/static/js/index.js | 216 ++++++++++++ lnbits/extensions/livestream/tasks.py | 93 +++++ .../templates/livestream/_api_docs.html | 146 ++++++++ .../templates/livestream/index.html | 322 ++++++++++++++++++ lnbits/extensions/livestream/views.py | 41 +++ lnbits/extensions/livestream/views_api.py | 107 ++++++ 13 files changed, 1464 insertions(+) create mode 100644 lnbits/extensions/livestream/README.md create mode 100644 lnbits/extensions/livestream/__init__.py create mode 100644 lnbits/extensions/livestream/config.json create mode 100644 lnbits/extensions/livestream/crud.py create mode 100644 lnbits/extensions/livestream/lnurl.py create mode 100644 lnbits/extensions/livestream/migrations.py create mode 100644 lnbits/extensions/livestream/models.py create mode 100644 lnbits/extensions/livestream/static/js/index.js create mode 100644 lnbits/extensions/livestream/tasks.py create mode 100644 lnbits/extensions/livestream/templates/livestream/_api_docs.html create mode 100644 lnbits/extensions/livestream/templates/livestream/index.html create mode 100644 lnbits/extensions/livestream/views.py create mode 100644 lnbits/extensions/livestream/views_api.py diff --git a/lnbits/extensions/livestream/README.md b/lnbits/extensions/livestream/README.md new file mode 100644 index 00000000..4e88e7bc --- /dev/null +++ b/lnbits/extensions/livestream/README.md @@ -0,0 +1,45 @@ +# DJ Livestream + +## Help DJ's and music producers conduct music livestreams + +LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet. + +When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional). + +The revenue will be sent to a wallet created specifically for that producer, with optional revenue splitting between the DJ and the producer. + +[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) + +[![video tutorial livestream](http://img.youtube.com/vi/zDrSWShKz7k/0.jpg)](https://youtu.be/zDrSWShKz7k 'video tutorial offline shop') + +## Usage + +1. Start by adding a track\ + ![add new track](https://i.imgur.com/Cu0eGrW.jpg) + - set the producer, or choose an existing one + - set the track name + - define a minimum price where a user can download the track + - set the download URL, where user will be redirected if he tips the livestream and the tip is equal or above the set price\ + ![track settings](https://i.imgur.com/HTJYwcW.jpg) +2. Adjust the percentage of the pay you want to take from the user's tips. 10%, the default, means that the DJ will keep 10% of all the tips sent by users. The other 90% will go to an auto generated producer wallet\ + ![adjust percentage](https://i.imgur.com/9weHKAB.jpg) +3. For every different producer added, when adding tracks, a wallet is generated for them\ + ![producer wallet](https://i.imgur.com/YFIZ7Tm.jpg) +4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed +5. After all tracks and producers are added, you can start "playing" songs\ + ![play tracks](https://i.imgur.com/7ytiBkq.jpg) +6. You'll see the current track playing and a green icon indicating active track also\ + ![active track](https://i.imgur.com/W1vBz54.jpg) +7. When a user scans the QR code, and sends a tip, you'll receive 10%, in the example case, in your wallet and the producer's wallet will get the rest. For example someone tips 100 sats, you'll get 10 sats and the producer will get 90 sats + - producer's wallet receiving 18 sats from 20 sats tips\ + ![producer wallet](https://i.imgur.com/OM9LawA.jpg) + +## Use cases + +You can print the QR code and display it on a live gig, a street performance, etc... OR you can use the QR as an overlay in an online stream of you playing music, doing a DJ set, making a podcast. + +You can use the extension's API to trigger updates for the current track, update fees, add tracks... + +## Sponsored by + +[![](https://cdn.shopify.com/s/files/1/0826/9235/files/cryptograffiti_logo_clear_background.png?v=1504730421)](https://cryptograffiti.com/) diff --git a/lnbits/extensions/livestream/__init__.py b/lnbits/extensions/livestream/__init__.py new file mode 100644 index 00000000..6b675b9d --- /dev/null +++ b/lnbits/extensions/livestream/__init__.py @@ -0,0 +1,36 @@ +import asyncio + +from fastapi import APIRouter +from fastapi.staticfiles import StaticFiles + +from lnbits.db import Database +from lnbits.helpers import template_renderer +from lnbits.tasks import catch_everything_and_restart + +db = Database("ext_livestream") + +livestream_static_files = [ + { + "path": "/livestream/static", + "app": StaticFiles(directory="lnbits/extensions/livestream/static"), + "name": "livestream_static", + } +] + +livestream_ext: APIRouter = APIRouter( + prefix="/livestream", + tags=["livestream"] +) + +def livestream_renderer(): + return template_renderer(["lnbits/extensions/livestream/templates"]) + +from .lnurl import * # noqa +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + + +def lnticket_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/livestream/config.json b/lnbits/extensions/livestream/config.json new file mode 100644 index 00000000..12ba6b79 --- /dev/null +++ b/lnbits/extensions/livestream/config.json @@ -0,0 +1,10 @@ +{ + "name": "DJ Livestream", + "short_description": "Sell tracks and split revenue (lnurl-pay)", + "icon": "speaker", + "contributors": [ + "fiatjaf", + "cryptograffiti" + ], + "hidden": false +} diff --git a/lnbits/extensions/livestream/crud.py b/lnbits/extensions/livestream/crud.py new file mode 100644 index 00000000..1b13bf08 --- /dev/null +++ b/lnbits/extensions/livestream/crud.py @@ -0,0 +1,200 @@ +from typing import List, Optional + +from lnbits.core.crud import create_account, create_wallet +from lnbits.db import SQLITE + +from . import db +from .models import Livestream, Producer, Track + + +async def create_livestream(*, wallet_id: str) -> int: + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await (method)( + f""" + INSERT INTO livestream.livestreams (wallet) + VALUES (?) + {returning} + """, + (wallet_id,), + ) + + if db.type == SQLITE: + return result._result_proxy.lastrowid + else: + return result[0] + + +async def get_livestream(ls_id: int) -> Optional[Livestream]: + row = await db.fetchone( + "SELECT * FROM livestream.livestreams WHERE id = ?", (ls_id,) + ) + return Livestream(**dict(row)) if row else None + + +async def get_livestream_by_track(track_id: int) -> Optional[Livestream]: + row = await db.fetchone( + """ + SELECT livestreams.* FROM livestream.livestreams + INNER JOIN tracks ON tracks.livestream = livestreams.id + WHERE tracks.id = ? + """, + (track_id,), + ) + return Livestream(**dict(row)) if row else None + + +async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]: + row = await db.fetchone( + "SELECT * FROM livestream.livestreams WHERE wallet = ?", (wallet,) + ) + + if not row: + # create on the fly + ls_id = await create_livestream(wallet_id=wallet) + return await get_livestream(ls_id) + + return Livestream(**dict(row)) if row else None + + +async def update_current_track(ls_id: int, track_id: Optional[int]): + await db.execute( + "UPDATE livestream.livestreams SET current_track = ? WHERE id = ?", + (track_id, ls_id), + ) + + +async def update_livestream_fee(ls_id: int, fee_pct: int): + await db.execute( + "UPDATE livestream.livestreams SET fee_pct = ? WHERE id = ?", + (fee_pct, ls_id), + ) + + +async def add_track( + livestream: int, + name: str, + download_url: Optional[str], + price_msat: int, + producer: Optional[int], +) -> int: + result = await db.execute( + """ + INSERT INTO livestream.tracks (livestream, name, download_url, price_msat, producer) + VALUES (?, ?, ?, ?, ?) + """, + (livestream, name, download_url, price_msat, producer), + ) + return result._result_proxy.lastrowid + + +async def update_track( + livestream: int, + track_id: int, + name: str, + download_url: Optional[str], + price_msat: int, + producer: int, +) -> int: + result = await db.execute( + """ + UPDATE livestream.tracks SET + name = ?, + download_url = ?, + price_msat = ?, + producer = ? + WHERE livestream = ? AND id = ? + """, + (name, download_url, price_msat, producer, livestream, track_id), + ) + return result._result_proxy.lastrowid + + +async def get_track(track_id: Optional[int]) -> Optional[Track]: + if not track_id: + return None + + row = await db.fetchone( + """ + SELECT id, download_url, price_msat, name, producer + FROM livestream.tracks WHERE id = ? + """, + (track_id,), + ) + return Track(**dict(row)) if row else None + + +async def get_tracks(livestream: int) -> List[Track]: + rows = await db.fetchall( + """ + SELECT id, download_url, price_msat, name, producer + FROM livestream.tracks WHERE livestream = ? + """, + (livestream,), + ) + return [Track(**dict(row)) for row in rows] + + +async def delete_track_from_livestream(livestream: int, track_id: int): + await db.execute( + """ + DELETE FROM livestream.tracks WHERE livestream = ? AND id = ? + """, + (livestream, track_id), + ) + + +async def add_producer(livestream: int, name: str) -> int: + name = name.strip() + + existing = await db.fetchall( + """ + SELECT id FROM livestream.producers + WHERE livestream = ? AND lower(name) = ? + """, + (livestream, name.lower()), + ) + if existing: + return existing[0].id + + user = await create_account() + wallet = await create_wallet(user_id=user.id, wallet_name="livestream: " + name) + + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await method( + f""" + INSERT INTO livestream.producers (livestream, name, "user", wallet) + VALUES (?, ?, ?, ?) + {returning} + """, + (livestream, name, user.id, wallet.id), + ) + if db.type == SQLITE: + return result._result_proxy.lastrowid + else: + return result[0] + + +async def get_producer(producer_id: int) -> Optional[Producer]: + row = await db.fetchone( + """ + SELECT id, "user", wallet, name + FROM livestream.producers WHERE id = ? + """, + (producer_id,), + ) + return Producer(**dict(row)) if row else None + + +async def get_producers(livestream: int) -> List[Producer]: + rows = await db.fetchall( + """ + SELECT id, "user", wallet, name + FROM livestream.producers WHERE livestream = ? + """, + (livestream,), + ) + return [Producer(**dict(row)) for row in rows] diff --git a/lnbits/extensions/livestream/lnurl.py b/lnbits/extensions/livestream/lnurl.py new file mode 100644 index 00000000..013cd245 --- /dev/null +++ b/lnbits/extensions/livestream/lnurl.py @@ -0,0 +1,120 @@ +import hashlib +import math +from http import HTTPStatus +from os import name + +from fastapi.exceptions import HTTPException +from fastapi.params import Query +from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse +from starlette.requests import Request # type: ignore + +from lnbits.core.services import create_invoice + +from . import livestream_ext +from .crud import get_livestream, get_livestream_by_track, get_track + + +@livestream_ext.get("/lnurl/{ls_id}", name="livestream.lnurl_livestream") +async def lnurl_livestream(ls_id, request: Request): + ls = await get_livestream(ls_id) + if not ls: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Livestream not found." + ) + + track = await get_track(ls.current_track) + if not track: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="This livestream is offline." + ) + + resp = LnurlPayResponse( + callback=request.url_for( + "livestream.lnurl_callback", track_id=track.id + ), + min_sendable=track.min_sendable, + max_sendable=track.max_sendable, + metadata=await track.lnurlpay_metadata(), + ) + + params = resp.dict() + params["commentAllowed"] = 300 + + return params + + +@livestream_ext.get("/lnurl/t/{track_id}", name="livestream.lnurl_track") +async def lnurl_track(track_id, request: Request): + track = await get_track(track_id) + if not track: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Track not found." + ) + + resp = LnurlPayResponse( + callback=request.url_for( + "livestream.lnurl_callback", track_id=track.id + ), + min_sendable=track.min_sendable, + max_sendable=track.max_sendable, + metadata=await track.lnurlpay_metadata(), + ) + + params = resp.dict() + params["commentAllowed"] = 300 + + return params + + +@livestream_ext.get("/lnurl/cb/{track_id}", name="livestream.lnurl_callback") +async def lnurl_callback(track_id, request: Request, amount: int = Query(...), comment: str = Query("")): + track = await get_track(track_id) + if not track: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Track not found." + ) + + amount_received = int(amount or 0) + + if amount_received < track.min_sendable: + return LnurlErrorResponse( + reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}." + ).dict() + elif track.max_sendable < amount_received: + return LnurlErrorResponse( + reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}." + ).dict() + + if len(comment or "") > 300: + return LnurlErrorResponse( + reason=f"Got a comment with {len(comment)} characters, but can only accept 300" + ).dict() + + ls = await get_livestream_by_track(track_id) + + payment_hash, payment_request = await create_invoice( + wallet_id=ls.wallet, + amount=int(amount_received / 1000), + memo=await track.fullname(), + description_hash=hashlib.sha256( + (await track.lnurlpay_metadata()).encode("utf-8") + ).digest(), + extra={"tag": "livestream", "track": track.id, "comment": comment}, + ) + + if amount_received < track.price_msat: + success_action = None + else: + success_action = track.success_action(payment_hash, request=request) + + resp = LnurlPayActionResponse( + pr=payment_request, + success_action=success_action, + routes=[], + ) + + return resp.dict() diff --git a/lnbits/extensions/livestream/migrations.py b/lnbits/extensions/livestream/migrations.py new file mode 100644 index 00000000..fb664ab1 --- /dev/null +++ b/lnbits/extensions/livestream/migrations.py @@ -0,0 +1,39 @@ +async def m001_initial(db): + """ + Initial livestream tables. + """ + await db.execute( + f""" + CREATE TABLE livestream.livestreams ( + id {db.serial_primary_key}, + wallet TEXT NOT NULL, + fee_pct INTEGER NOT NULL DEFAULT 10, + current_track INTEGER + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE livestream.producers ( + livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id), + id {db.serial_primary_key}, + "user" TEXT NOT NULL, + wallet TEXT NOT NULL, + name TEXT NOT NULL + ); + """ + ) + + await db.execute( + f""" + CREATE TABLE livestream.tracks ( + livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id), + id {db.serial_primary_key}, + download_url TEXT, + price_msat INTEGER NOT NULL DEFAULT 0, + name TEXT, + producer INTEGER REFERENCES {db.references_schema}producers (id) NOT NULL + ); + """ + ) diff --git a/lnbits/extensions/livestream/models.py b/lnbits/extensions/livestream/models.py new file mode 100644 index 00000000..8d3094c0 --- /dev/null +++ b/lnbits/extensions/livestream/models.py @@ -0,0 +1,89 @@ +import json +from typing import Optional + +from fastapi.params import Query +from lnurl import Lnurl +from lnurl import encode as lnurl_encode # type: ignore +from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore +from lnurl.types import LnurlPayMetadata # type: ignore +from pydantic.main import BaseModel +from starlette.requests import Request + + +class CreateTrack(BaseModel): + name: str = Query(...) + download_url: str = Query(None) + price_msat: int = Query(None, ge=0) + producer_id: str = Query(None) + producer_name: str = Query(None) + +class Livestream(BaseModel): + id: int + wallet: str + fee_pct: int + current_track: Optional[int] + + def lnurl(self, request: Request) -> Lnurl: + url = request.url_for("livestream.lnurl_livestream", ls_id=self.id) + return lnurl_encode(url) + + +class Track(BaseModel): + id: int + download_url: Optional[str] + price_msat: Optional[int] + name: str + producer: int + + @property + def min_sendable(self) -> int: + return min(100_000, self.price_msat or 100_000) + + @property + def max_sendable(self) -> int: + return max(50_000_000, self.price_msat * 5) + + def lnurl(self, request: Request) -> Lnurl: + url = request.url_for("livestream.lnurl_track", track_id=self.id) + return lnurl_encode(url) + + async def fullname(self) -> str: + from .crud import get_producer + + producer = await get_producer(self.producer) + if producer: + producer_name = producer.name + else: + producer_name = "unknown author" + + return f"'{self.name}', from {producer_name}." + + async def lnurlpay_metadata(self) -> LnurlPayMetadata: + description = ( + await self.fullname() + ) + " Like this track? Send some sats in appreciation." + + if self.download_url: + description += f" Send {round(self.price_msat/1000)} sats or more and you can download it." + + return LnurlPayMetadata(json.dumps([["text/plain", description]])) + + def success_action(self, payment_hash: str, request: Request) -> Optional[LnurlPaySuccessAction]: + if not self.download_url: + return None + + return UrlAction( + url=request.url_for( + "livestream.track_redirect_download", + track_id=self.id, + p=payment_hash + ), + description=f"Download the track {self.name}!", + ) + + +class Producer(BaseModel): + id: int + user: str + wallet: str + name: str diff --git a/lnbits/extensions/livestream/static/js/index.js b/lnbits/extensions/livestream/static/js/index.js new file mode 100644 index 00000000..c49befce --- /dev/null +++ b/lnbits/extensions/livestream/static/js/index.js @@ -0,0 +1,216 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + cancelListener: () => {}, + selectedWallet: null, + nextCurrentTrack: null, + livestream: { + tracks: [], + producers: [] + }, + trackDialog: { + show: false, + data: {} + } + } + }, + computed: { + sortedTracks() { + return this.livestream.tracks.sort((a, b) => a.name - b.name) + }, + tracksMap() { + return Object.fromEntries( + this.livestream.tracks.map(track => [track.id, track]) + ) + }, + producersMap() { + return Object.fromEntries( + this.livestream.producers.map(prod => [prod.id, prod]) + ) + } + }, + methods: { + getTrackLabel(trackId) { + if (!trackId) return + let track = this.tracksMap[trackId] + return `${track.name}, ${this.producersMap[track.producer].name}` + }, + disabledAddTrackButton() { + return ( + !this.trackDialog.data.name || + this.trackDialog.data.name.length === 0 || + !this.trackDialog.data.producer || + this.trackDialog.data.producer.length === 0 + ) + }, + changedWallet(wallet) { + this.selectedWallet = wallet + this.loadLivestream() + this.startPaymentNotifier() + }, + loadLivestream() { + LNbits.api + .request( + 'GET', + '/livestream/api/v1/livestream', + this.selectedWallet.inkey + ) + .then(response => { + this.livestream = response.data + this.nextCurrentTrack = this.livestream.current_track + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + startPaymentNotifier() { + this.cancelListener() + + this.cancelListener = LNbits.events.onInvoicePaid( + this.selectedWallet, + payment => { + let satoshiAmount = Math.round(payment.amount / 1000) + let trackName = ( + this.tracksMap[payment.extra.track] || {name: '[unknown]'} + ).name + + this.$q.notify({ + message: `Someone paid ${satoshiAmount} sat for the track ${trackName}.`, + caption: payment.extra.comment + ? `"${payment.extra.comment}"` + : undefined, + color: 'secondary', + html: true, + timeout: 0, + actions: [{label: 'Dismiss', color: 'white', handler: () => {}}] + }) + } + ) + }, + addTrack() { + let {id, name, producer, price_sat, download_url} = this.trackDialog.data + + const [method, path] = id + ? ['PUT', `/livestream/api/v1/livestream/tracks/${id}`] + : ['POST', '/livestream/api/v1/livestream/tracks'] + + LNbits.api + .request(method, path, this.selectedWallet.inkey, { + download_url: + download_url && download_url.length > 0 ? download_url : undefined, + name, + price_msat: price_sat * 1000 || 0, + producer_name: typeof producer === 'string' ? producer : undefined, + producer_id: typeof producer === 'object' ? producer.id : undefined + }) + .then(response => { + this.$q.notify({ + message: `Track '${this.trackDialog.data.name}' added.`, + timeout: 700 + }) + this.loadLivestream() + this.trackDialog.show = false + this.trackDialog.data = {} + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + openAddTrackDialog() { + this.trackDialog.show = true + this.trackDialog.data = {} + }, + openUpdateDialog(itemId) { + this.trackDialog.show = true + let item = this.livestream.tracks.find(item => item.id === itemId) + this.trackDialog.data = { + ...item, + producer: this.livestream.producers.find( + prod => prod.id === item.producer + ), + price_sat: Math.round(item.price_msat / 1000) + } + }, + deleteTrack(trackId) { + LNbits.utils + .confirmDialog('Are you sure you want to delete this track?') + .onOk(() => { + LNbits.api + .request( + 'DELETE', + '/livestream/api/v1/livestream/tracks/' + trackId, + this.selectedWallet.inkey + ) + .then(response => { + this.$q.notify({ + message: `Track deleted`, + timeout: 700 + }) + this.livestream.tracks.splice( + this.livestream.tracks.findIndex(track => track.id === trackId), + 1 + ) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + }, + updateCurrentTrack(track) { + console.log(this.nextCurrentTrack, this.livestream) + if (this.livestream.current_track === track) { + // if clicking the same, stop it + track = 0 + } + + LNbits.api + .request( + 'PUT', + '/livestream/api/v1/livestream/track/' + track, + this.selectedWallet.inkey + ) + .then(() => { + this.livestream.current_track = track + this.nextCurrentTrack = track + this.$q.notify({ + message: `Current track updated.`, + timeout: 700 + }) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + updateFeePct() { + LNbits.api + .request( + 'PUT', + '/livestream/api/v1/livestream/fee/' + this.livestream.fee_pct, + this.selectedWallet.inkey + ) + .then(() => { + this.$q.notify({ + message: `Percentage updated.`, + timeout: 700 + }) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + producerAdded(added, cb) { + cb(added) + } + }, + created() { + this.selectedWallet = this.g.user.wallets[0] + this.loadLivestream() + this.startPaymentNotifier() + } +}) diff --git a/lnbits/extensions/livestream/tasks.py b/lnbits/extensions/livestream/tasks.py new file mode 100644 index 00000000..1b2b4297 --- /dev/null +++ b/lnbits/extensions/livestream/tasks.py @@ -0,0 +1,93 @@ +import asyncio +import json + +from lnbits.core import db as core_db +from lnbits.core.crud import create_payment +from lnbits.core.models import Payment +from lnbits.helpers import urlsafe_short_hash +from lnbits.tasks import internal_invoice_listener, register_invoice_listener + +from .crud import get_livestream_by_track, get_producer, get_track + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + +# async def register_listeners(): +# invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2) +# register_invoice_listener(invoice_paid_chan_send) +# await wait_for_paid_invoices(invoice_paid_chan_recv) + + +async def on_invoice_paid(payment: Payment) -> None: + if "livestream" != payment.extra.get("tag"): + # not a livestream invoice + return + + track = await get_track(payment.extra.get("track", -1)) + if not track: + print("this should never happen", payment) + return + + if payment.extra.get("shared_with"): + print("payment was shared already", payment) + return + + producer = await get_producer(track.producer) + assert producer, f"track {track.id} is not associated with a producer" + + ls = await get_livestream_by_track(track.id) + assert ls, f"track {track.id} is not associated with a livestream" + + # now we make a special kind of internal transfer + amount = int(payment.amount * (100 - ls.fee_pct) / 100) + + # mark the original payment with two extra keys, "shared_with" and "received" + # (this prevents us from doing this process again and it's informative) + # and reduce it by the amount we're going to send to the producer + await core_db.execute( + """ + UPDATE apipayments + SET extra = ?, amount = ? + WHERE hash = ? + AND checking_id NOT LIKE 'internal_%' + """, + ( + json.dumps( + dict( + **payment.extra, + shared_with=[producer.name, producer.id], + received=payment.amount, + ) + ), + payment.amount - amount, + payment.payment_hash, + ), + ) + + # perform an internal transfer using the same payment_hash to the producer wallet + internal_checking_id = f"internal_{urlsafe_short_hash()}" + await create_payment( + wallet_id=producer.wallet, + checking_id=internal_checking_id, + payment_request="", + payment_hash=payment.payment_hash, + amount=amount, + memo=f"Revenue from '{track.name}'.", + pending=False, + ) + + # manually send this for now + # await internal_invoice_paid.send(internal_checking_id) + await internal_invoice_listener.put(internal_checking_id) + + # so the flow is the following: + # - we receive, say, 1000 satoshis + # - if the fee_pct is, say, 30%, the amount we will send is 700 + # - we change the amount of receiving payment on the database from 1000 to 300 + # - we create a new payment on the producer's wallet with amount 700 diff --git a/lnbits/extensions/livestream/templates/livestream/_api_docs.html b/lnbits/extensions/livestream/templates/livestream/_api_docs.html new file mode 100644 index 00000000..4c497d7f --- /dev/null +++ b/lnbits/extensions/livestream/templates/livestream/_api_docs.html @@ -0,0 +1,146 @@ + + + +

Add tracks, profit.

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

+ Standalone QR Code for this track +

+ + + + + + + Copy LNURL-pay code +
+ + + + + + + +
+
+ + Update track + Add track + +
+
+ Cancel +
+
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/livestream/views.py b/lnbits/extensions/livestream/views.py new file mode 100644 index 00000000..a56fadb1 --- /dev/null +++ b/lnbits/extensions/livestream/views.py @@ -0,0 +1,41 @@ +from http import HTTPStatus +from mmap import MAP_DENYWRITE + +from fastapi.param_functions import Depends +from fastapi.params import Query +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse + +from lnbits.core.crud import get_wallet_payment +from lnbits.core.models import Payment, User +from lnbits.decorators import check_user_exists + +from . import livestream_ext, livestream_renderer +from .crud import get_livestream_by_track, get_track + + +@livestream_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return livestream_renderer().TemplateResponse("livestream/index.html", {"request": request, "user": user.dict()}) + + +@livestream_ext.get("/track/{track_id}", name="livestream.track_redirect_download") +async def track_redirect_download(track_id, request: Request): + payment_hash = request.path_params["p"] + track = await get_track(track_id) + ls = await get_livestream_by_track(track_id) + payment: Payment = await get_wallet_payment(ls.wallet, payment_hash) + + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Couldn't find the payment {payment_hash} or track {track.id}." + ) + + if payment.pending: + raise HTTPException( + status_code=HTTPStatus.PAYMENT_REQUIRED, + detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute." + ) + return RedirectResponse(url=track.download_url) diff --git a/lnbits/extensions/livestream/views_api.py b/lnbits/extensions/livestream/views_api.py new file mode 100644 index 00000000..9dc9cdcc --- /dev/null +++ b/lnbits/extensions/livestream/views_api.py @@ -0,0 +1,107 @@ +from http import HTTPStatus + +from fastapi.param_functions import Depends +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl +from starlette.exceptions import HTTPException +from starlette.requests import Request # type: ignore + +from lnbits.decorators import WalletTypeInfo, get_key_type +from lnbits.extensions.livestream.models import CreateTrack + +from . import livestream_ext +from .crud import ( + add_producer, + add_track, + delete_track_from_livestream, + get_or_create_livestream_by_wallet, + get_producers, + get_tracks, + update_current_track, + update_livestream_fee, + update_track, +) + + +@livestream_ext.get("/api/v1/livestream") +async def api_livestream_from_wallet(req: Request, g: WalletTypeInfo = Depends(get_key_type)): + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + tracks = await get_tracks(ls.id) + producers = await get_producers(ls.id) + print("INIT", ls, tracks, producers) + try: + return { + **ls.dict(), + **{ + "lnurl": ls.lnurl(request=req), + "tracks": [ + dict(lnurl=track.lnurl(request=req), **track.dict()) + for track in tracks + ], + "producers": [producer.dict() for producer in producers], + }, + } + except LnurlInvalidUrl: + raise HTTPException( + status_code=HTTPStatus.UPGRADE_REQUIRED, + detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor." + ) + + +@livestream_ext.put("/api/v1/livestream/track/{track_id}") +async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)): + try: + id = int(track_id) + except ValueError: + id = 0 + if id <= 0: + id = None + + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + await update_current_track(ls.id, id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +@livestream_ext.put("/api/v1/livestream/fee/{fee_pct}") +async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)): + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + await update_livestream_fee(ls.id, int(fee_pct)) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +@livestream_ext.post("/api/v1/livestream/tracks") +@livestream_ext.put("/api/v1/livestream/tracks/{id}") +async def api_add_track(data: CreateTrack, id=None, g: WalletTypeInfo = Depends(get_key_type)): + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + + if data.producer_id: + p_id = data.producer_id + elif data.producer_name: + p_id = await add_producer(ls.id, data.producer_name) + else: + raise TypeError("need either producer_id or producer_name arguments") + + if id: + await update_track( + ls.id, + id, + data.name, + data.download_url, + data.price_msat or 0, + p_id, + ) + else: + await add_track( + ls.id, + data.name, + data.download_url, + data.price_msat or 0, + p_id, + ) + return + + +@livestream_ext.route("/api/v1/livestream/tracks/{track_id}") +async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)): + ls = await get_or_create_livestream_by_wallet(g.wallet.id) + await delete_track_from_livestream(ls.id, track_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) From cfe7fc5e5850cf679f3b0c0822d5ae64a7b3badd Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 29 Oct 2021 12:44:45 +0100 Subject: [PATCH 25/27] streamalerts converted untested --- lnbits/extensions/streamalerts/README.md | 39 ++ lnbits/extensions/streamalerts/__init__.py | 17 + lnbits/extensions/streamalerts/config.json | 6 + lnbits/extensions/streamalerts/crud.py | 283 ++++++++++ lnbits/extensions/streamalerts/migrations.py | 35 ++ lnbits/extensions/streamalerts/models.py | 65 +++ .../templates/streamalerts/_api_docs.html | 18 + .../templates/streamalerts/display.html | 97 ++++ .../templates/streamalerts/index.html | 502 ++++++++++++++++++ lnbits/extensions/streamalerts/views.py | 39 ++ lnbits/extensions/streamalerts/views_api.py | 269 ++++++++++ 11 files changed, 1370 insertions(+) create mode 100644 lnbits/extensions/streamalerts/README.md create mode 100644 lnbits/extensions/streamalerts/__init__.py create mode 100644 lnbits/extensions/streamalerts/config.json create mode 100644 lnbits/extensions/streamalerts/crud.py create mode 100644 lnbits/extensions/streamalerts/migrations.py create mode 100644 lnbits/extensions/streamalerts/models.py create mode 100644 lnbits/extensions/streamalerts/templates/streamalerts/_api_docs.html create mode 100644 lnbits/extensions/streamalerts/templates/streamalerts/display.html create mode 100644 lnbits/extensions/streamalerts/templates/streamalerts/index.html create mode 100644 lnbits/extensions/streamalerts/views.py create mode 100644 lnbits/extensions/streamalerts/views_api.py diff --git a/lnbits/extensions/streamalerts/README.md b/lnbits/extensions/streamalerts/README.md new file mode 100644 index 00000000..726ffe76 --- /dev/null +++ b/lnbits/extensions/streamalerts/README.md @@ -0,0 +1,39 @@ +

Stream Alerts

+

Integrate Bitcoin Donations into your livestream alerts

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

How to set it up

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

CONGRATS! Let the sats flow!

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

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

+

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

+
+
diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/display.html b/lnbits/extensions/streamalerts/templates/streamalerts/display.html new file mode 100644 index 00000000..a10e64d8 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/display.html @@ -0,0 +1,97 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
Donate Bitcoin to {{ twitchuser }}
+
+ + + + +
+ Submit +
+
+
+
+
+
+ +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/index.html b/lnbits/extensions/streamalerts/templates/streamalerts/index.html new file mode 100644 index 00000000..46d1bb31 --- /dev/null +++ b/lnbits/extensions/streamalerts/templates/streamalerts/index.html @@ -0,0 +1,502 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Service + + + + + +
+
+
Services
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + +
+
+
Donations
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+
+
+ + +
+ {{SITE_TITLE}} Stream Alerts extension +
+
+ + + {% include "streamalerts/_api_docs.html" %} + +
+
+ + + + + + +
+
+
+ +
+
+ + + Watch-Only extension MUST be activated and have a wallet + + +
+
+
+
+ +
+ + + + +
+ Update Service + + Create Service + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/streamalerts/views.py b/lnbits/extensions/streamalerts/views.py new file mode 100644 index 00000000..bd6c246d --- /dev/null +++ b/lnbits/extensions/streamalerts/views.py @@ -0,0 +1,39 @@ +from http import HTTPStatus + +from fastapi.param_functions import Depends +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import streamalerts_ext, streamalerts_renderer +from .crud import get_service + +templates = Jinja2Templates(directory="templates") + + +@streamalerts_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + """Return the extension's settings page""" + return streamalerts_renderer().TemplateResponse("streamalerts/index.html", {"request": request, "user": user.dict()}) + + +@streamalerts_ext.get("/{state}") +async def donation(state, request: Request): + """Return the donation form for the Service corresponding to state""" + service = await get_service(0, by_state=state) + if not service: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Service does not exist." + ) + return streamalerts_renderer().TemplateResponse( + "streamalerts/display.html", + { + "request": request, + "twitchuser": service.twitchuser, + "service":service.id + } + ) diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py new file mode 100644 index 00000000..a90b4ae4 --- /dev/null +++ b/lnbits/extensions/streamalerts/views_api.py @@ -0,0 +1,269 @@ +from http import HTTPStatus + +from fastapi.params import Depends, Query +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import RedirectResponse + +from lnbits.core.crud import get_user +from lnbits.decorators import WalletTypeInfo, get_key_type +from lnbits.extensions.streamalerts.models import ( + CreateDonation, + CreateService, + ValidateDonation, +) +from lnbits.utils.exchange_rates import btc_price + +from ..satspay.crud import create_charge, get_charge +from . import streamalerts_ext +from .crud import ( + authenticate_service, + create_donation, + create_service, + delete_donation, + delete_service, + get_charge_details, + get_donation, + get_donations, + get_service, + get_service_redirect_uri, + get_services, + post_donation, + update_donation, + update_service, +) + + +@streamalerts_ext.post("/api/v1/services") +async def api_create_service(data : CreateService, wallet: WalletTypeInfo = Depends(get_key_type)): + """Create a service, which holds data about how/where to post donations""" + try: + service = await create_service(data=data) + except Exception as e: + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e) + ) + + return service.dict() + + +@streamalerts_ext.get("/api/v1/getaccess/{service_id}") +async def api_get_access(service_id, request: Request): + """Redirect to Streamlabs' Approve/Decline page for API access for Service + with service_id + """ + service = await get_service(service_id) + if service: + redirect_uri = await get_service_redirect_uri(request, service_id) + params = { + "response_type": "code", + "client_id": service.client_id, + "redirect_uri": redirect_uri, + "scope": "donations.create", + "state": service.state, + } + endpoint_url = "https://streamlabs.com/api/v1.0/authorize/?" + querystring = "&".join([f"{key}={value}" for key, value in params.items()]) + redirect_url = endpoint_url + querystring + return RedirectResponse(redirect_url) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Service does not exist!" + ) + +@streamalerts_ext.get("/api/v1/authenticate/{service_id}") +async def api_authenticate_service(service_id, request: Request, code: str = Query(...), state: str = Query(...)): + """Endpoint visited via redirect during third party API authentication + + If successful, an API access token will be added to the service, and + the user will be redirected to index.html. + """ + + service = await get_service(service_id) + if service.state != state: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="State doesn't match!" + ) + + redirect_uri = request.scheme + "://" + request.headers["Host"] + redirect_uri += f"/streamalerts/api/v1/authenticate/{service_id}" + url, success = await authenticate_service(service_id, code, redirect_uri) + if success: + return RedirectResponse(url) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Service already authenticated!" + ) + + +@streamalerts_ext.post("/api/v1/donations") +async def api_create_donation(data: CreateDonation, request: Request): + """Take data from donation form and return satspay charge""" + # Currency is hardcoded while frotnend is limited + cur_code = "USD" + sats = data.sats + message = data.message + # Fiat amount is calculated here while frontend is limited + price = await btc_price(cur_code) + amount = sats * (10 ** (-8)) * price + webhook_base = request.scheme + "://" + request.headers["Host"] + service_id = data.service + service = await get_service(service_id) + charge_details = await get_charge_details(service.id) + name = data.name + + description = f"{sats} sats donation from {name} to {service.twitchuser}" + charge = await create_charge( + amount=sats, + completelink=f"https://twitch.tv/{service.twitchuser}", + completelinktext="Back to Stream!", + webhook=webhook_base + "/streamalerts/api/v1/postdonation", + description=description, + **charge_details, + ) + await create_donation( + id=charge.id, + wallet=service.wallet, + message=message, + name=name, + cur_code=cur_code, + sats=data.sats, + amount=amount, + service=data.service + ) + return {"redirect_url": f"/satspay/{charge.id}"} + + +@streamalerts_ext.post("/api/v1/postdonation") +async def api_post_donation(request: Request, data: ValidateDonation): + """Post a paid donation to Stremalabs/StreamElements. + This endpoint acts as a webhook for the SatsPayServer extension.""" + + donation_id = data.id + charge = await get_charge(donation_id) + if charge and charge.paid: + return await post_donation(donation_id) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Not a paid charge!" + ) + + +@streamalerts_ext.get("/api/v1/services") +async def api_get_services(g: WalletTypeInfo = Depends(get_key_type)): + """Return list of all services assigned to wallet with given invoice key""" + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + services = [] + for wallet_id in wallet_ids: + new_services = await get_services(wallet_id) + services += new_services if new_services else [] + return [service.dict() for service in services] if services else [] + + +@streamalerts_ext.get("/api/v1/donations") +async def api_get_donations(g: WalletTypeInfo = Depends(get_key_type)): + """Return list of all donations assigned to wallet with given invoice + key + """ + wallet_ids = (await get_user(g.wallet.user)).wallet_ids + donations = [] + for wallet_id in wallet_ids: + new_donations = await get_donations(wallet_id) + donations += new_donations if new_donations else [] + return [donation._asdict() for donation in donations] if donations else [] + + +@streamalerts_ext.put("/api/v1/donations/{donation_id}") +async def api_update_donation(data: CreateDonation, donation_id=None, g: WalletTypeInfo = Depends(get_key_type)): + """Update a donation with the data given in the request""" + if donation_id: + donation = await get_donation(donation_id) + + if not donation: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Donation does not exist." + ) + + + if donation.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not your donation." + ) + + donation = await update_donation(donation_id, **data.dict()) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No donation ID specified" + ) + + return donation.dict() + + +@streamalerts_ext.put("/api/v1/services/{service_id}") +async def api_update_service(data: CreateService, service_id=None, g: WalletTypeInfo = Depends(get_key_type)): + """Update a service with the data given in the request""" + if service_id: + service = await get_service(service_id) + + if not service: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Service does not exist." + ) + + if service.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not your service." + ) + + service = await update_service(service_id, **data.dict()) + else: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No service ID specified" + ) + return service.dict() + + +@streamalerts_ext.delete("/api/v1/donations/{donation_id}") +async def api_delete_donation(donation_id, g: WalletTypeInfo = Depends(get_key_type)): + """Delete the donation with the given donation_id""" + donation = await get_donation(donation_id) + if not donation: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No donation with this ID!" + ) + if donation.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not authorized to delete this donation!" + ) + await delete_donation(donation_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +@streamalerts_ext.delete("/api/v1/services/{service_id}") +async def api_delete_service(service_id, g: WalletTypeInfo = Depends(get_key_type)): + """Delete the service with the given service_id""" + service = await get_service(service_id) + if not service: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="No service with this ID!" + ) + if service.wallet != g.wallet.id: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, + detail="Not authorized to delete this service!" + ) + await delete_service(service_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) From ea99fcb2032b89063638eff72a0a415634d6b4f4 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 29 Oct 2021 12:50:43 +0100 Subject: [PATCH 26/27] streamalert fix request scheme --- lnbits/extensions/streamalerts/crud.py | 2 +- lnbits/extensions/streamalerts/views_api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lnbits/extensions/streamalerts/crud.py b/lnbits/extensions/streamalerts/crud.py index 5992cb77..cc62c75f 100644 --- a/lnbits/extensions/streamalerts/crud.py +++ b/lnbits/extensions/streamalerts/crud.py @@ -14,7 +14,7 @@ from .models import CreateService, Donation, Service async def get_service_redirect_uri(request, service_id): """Return the service's redirect URI, to be given to the third party API""" - uri_base = request.scheme + "://" + uri_base = request.url.scheme + "://" uri_base += request.headers["Host"] + "/streamalerts/api/v1" redirect_uri = uri_base + f"/authenticate/{service_id}" return redirect_uri diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py index a90b4ae4..0fdbb446 100644 --- a/lnbits/extensions/streamalerts/views_api.py +++ b/lnbits/extensions/streamalerts/views_api.py @@ -87,7 +87,7 @@ async def api_authenticate_service(service_id, request: Request, code: str = Que detail="State doesn't match!" ) - redirect_uri = request.scheme + "://" + request.headers["Host"] + redirect_uri = request.url.scheme + "://" + request.headers["Host"] redirect_uri += f"/streamalerts/api/v1/authenticate/{service_id}" url, success = await authenticate_service(service_id, code, redirect_uri) if success: @@ -109,7 +109,7 @@ async def api_create_donation(data: CreateDonation, request: Request): # Fiat amount is calculated here while frontend is limited price = await btc_price(cur_code) amount = sats * (10 ** (-8)) * price - webhook_base = request.scheme + "://" + request.headers["Host"] + webhook_base = request.url.scheme + "://" + request.headers["Host"] service_id = data.service service = await get_service(service_id) charge_details = await get_charge_details(service.id) From a276764f12db4c3710e0e355bad991b24858a782 Mon Sep 17 00:00:00 2001 From: Tiago vasconcelos Date: Fri, 29 Oct 2021 13:06:59 +0100 Subject: [PATCH 27/27] wallet fiat conversion fix --- lnbits/core/views/api.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index 95d32cb1..91573bcf 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -5,16 +5,17 @@ from binascii import unhexlify from http import HTTPStatus from typing import Dict, Optional, Union from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse -from lnbits.bolt11 import Invoice + import httpx from fastapi import Query, Request from fastapi.exceptions import HTTPException from fastapi.param_functions import Depends from fastapi.params import Body -from sse_starlette.sse import EventSourceResponse from pydantic import BaseModel +from sse_starlette.sse import EventSourceResponse from lnbits import bolt11, lnurl +from lnbits.bolt11 import Invoice from lnbits.core.models import Payment, Wallet from lnbits.decorators import ( WalletAdminKeyChecker, @@ -29,17 +30,17 @@ from lnbits.utils.exchange_rates import currencies, fiat_amount_as_satoshis from .. import core_app, db from ..crud import ( get_payments, + get_standalone_payment, save_balance_check, update_wallet, - get_standalone_payment, ) from ..services import ( InvoiceFailure, PaymentFailure, + check_invoice_status, create_invoice, pay_invoice, perform_lnurlauth, - check_invoice_status, ) from ..tasks import api_invoice_listeners @@ -100,8 +101,7 @@ async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet): else: description_hash = b"" memo = data.memo - - if data.unit or "sat" == "sat": + if data.unit == "sat": amount = data.amount else: price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit)