diff --git a/lnbits/core/models.py b/lnbits/core/models.py index e292362a..31383667 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -88,7 +88,7 @@ class Payment(BaseModel): preimage: str payment_hash: str expiry: Optional[float] - extra: Optional[Dict] = {} + extra: Dict = {} wallet_id: str webhook: Optional[str] webhook_status: Optional[int] diff --git a/lnbits/core/templates/core/wallet.html b/lnbits/core/templates/core/wallet.html index 0f68a0fe..46cfc690 100644 --- a/lnbits/core/templates/core/wallet.html +++ b/lnbits/core/templates/core/wallet.html @@ -468,10 +468,10 @@ dense v-model.number="receive.data.amount" :label="'Amount (' + receive.unit + ') *'" - :mask="receive.unit != 'sats' ? '#.##' : '#'" + :mask="receive.unit != 'sat' ? '#.##' : '#'" fill-mask="0" reverse-fill-mask - :step="receive.unit != 'sats' ? '0.01' : '1'" + :step="receive.unit != 'sat' ? '0.01' : '1'" :min="receive.minMax[0]" :max="receive.minMax[1]" :readonly="receive.lnurl && receive.lnurl.fixed" diff --git a/lnbits/extensions/boltcards/crud.py b/lnbits/extensions/boltcards/crud.py index 4fae31f9..cc5d5193 100644 --- a/lnbits/extensions/boltcards/crud.py +++ b/lnbits/extensions/boltcards/crud.py @@ -1,5 +1,5 @@ import secrets -from datetime import date, datetime +from datetime import datetime from typing import List, Optional, Union from lnbits.helpers import urlsafe_short_hash @@ -124,7 +124,6 @@ async def get_card_by_otp(otp: str) -> Optional[Card]: async def delete_card(card_id: str) -> None: # Delete cards - card = await get_card(card_id) await db.execute("DELETE FROM boltcards.cards WHERE id = ?", (card_id,)) # Delete hits hits = await get_hits([card_id]) @@ -146,7 +145,7 @@ async def update_card_counter(counter: int, id: str): async def enable_disable_card(enable: bool, id: str) -> Optional[Card]: - row = await db.execute( + await db.execute( "UPDATE boltcards.cards SET enable = ? WHERE id = ?", (enable, id), ) @@ -161,7 +160,7 @@ async def update_card_otp(otp: str, id: str): async def get_hit(hit_id: str) -> Optional[Hit]: - row = await db.fetchone(f"SELECT * FROM boltcards.hits WHERE id = ?", (hit_id)) + row = await db.fetchone(f"SELECT * FROM boltcards.hits WHERE id = ?", (hit_id,)) if not row: return None @@ -182,7 +181,7 @@ async def get_hits(cards_ids: Union[str, List[str]]) -> List[Hit]: return [Hit(**row) for row in rows] -async def get_hits_today(card_id: str) -> Optional[Hit]: +async def get_hits_today(card_id: str) -> List[Hit]: rows = await db.fetchall( f"SELECT * FROM boltcards.hits WHERE card_id = ?", (card_id,), @@ -259,7 +258,7 @@ async def create_refund(hit_id, refund_amount) -> Refund: async def get_refund(refund_id: str) -> Optional[Refund]: row = await db.fetchone( - f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id) + f"SELECT * FROM boltcards.refunds WHERE id = ?", (refund_id,) ) if not row: return None @@ -267,7 +266,7 @@ async def get_refund(refund_id: str) -> Optional[Refund]: return Refund.parse_obj(refund) -async def get_refunds(hits_ids: Union[str, List[str]]) -> List[Refund]: +async def get_refunds(hits_ids: List[Hit]) -> List[Refund]: if len(hits_ids) == 0: return [] diff --git a/lnbits/extensions/boltcards/lnurl.py b/lnbits/extensions/boltcards/lnurl.py index 3a99073a..d0430372 100644 --- a/lnbits/extensions/boltcards/lnurl.py +++ b/lnbits/extensions/boltcards/lnurl.py @@ -3,13 +3,9 @@ import secrets from http import HTTPStatus from urllib.parse import urlparse -from fastapi import Request -from fastapi.param_functions import Query -from fastapi.params import Depends, Query -from lnurl import encode as lnurl_encode # type: ignore -from lnurl.types import LnurlPayMetadata # type: ignore -from starlette.exceptions import HTTPException -from starlette.requests import Request +from fastapi import HTTPException, Query, Request +from lnurl import encode as lnurl_encode +from lnurl.types import LnurlPayMetadata from starlette.responses import HTMLResponse from lnbits import bolt11 @@ -28,14 +24,13 @@ from .crud import ( update_card_counter, update_card_otp, ) -from .models import CreateCardData from .nxp424 import decryptSUN, getSunMAC ###############LNURLWITHDRAW################# # /boltcards/api/v1/scan?p=00000000000000000000000000000000&c=0000000000000000 @boltcards_ext.get("/api/v1/scan/{external_id}") -async def api_scan(p, c, request: Request, external_id: str = None): +async def api_scan(p, c, request: Request, external_id: str = Query(None)): # some wallets send everything as lower case, no bueno p = p.upper() c = c.upper() @@ -63,6 +58,7 @@ async def api_scan(p, c, request: Request, external_id: str = None): await update_card_counter(ctr_int, card.id) # gathering some info for hit record + assert request.client ip = request.client.host if "x-real-ip" in request.headers: ip = request.headers["x-real-ip"] @@ -95,7 +91,6 @@ async def api_scan(p, c, request: Request, external_id: str = None): name="boltcards.lnurl_callback", ) async def lnurl_callback( - request: Request, pr: str = Query(None), k1: str = Query(None), ): @@ -120,7 +115,9 @@ async def lnurl_callback( return {"status": "ERROR", "reason": "Failed to decode payment request"} card = await get_card(hit.card_id) + assert card hit = await spend_hit(id=hit.id, amount=int(invoice.amount_msat / 1000)) + assert hit try: await pay_invoice( wallet_id=card.wallet, @@ -155,7 +152,7 @@ async def api_auth(a, request: Request): response = { "card_name": card.card_name, - "id": 1, + "id": str(1), "k0": card.k0, "k1": card.k1, "k2": card.k2, @@ -163,7 +160,7 @@ async def api_auth(a, request: Request): "k4": card.k2, "lnurlw_base": "lnurlw://" + lnurlw_base, "protocol_name": "new_bolt_card_response", - "protocol_version": 1, + "protocol_version": str(1), } return response @@ -179,7 +176,9 @@ async def api_auth(a, request: Request): ) async def lnurlp_response(req: Request, hit_id: str = Query(None)): hit = await get_hit(hit_id) + assert hit card = await get_card(hit.card_id) + assert card if not hit: return {"status": "ERROR", "reason": f"LNURL-pay record not found."} if not card.enable: @@ -199,17 +198,17 @@ async def lnurlp_response(req: Request, hit_id: str = Query(None)): response_class=HTMLResponse, name="boltcards.lnurlp_callback", ) -async def lnurlp_callback( - req: Request, hit_id: str = Query(None), amount: str = Query(None) -): +async def lnurlp_callback(hit_id: str = Query(None), amount: str = Query(None)): hit = await get_hit(hit_id) + assert hit card = await get_card(hit.card_id) + assert card if not hit: return {"status": "ERROR", "reason": f"LNURL-pay record not found."} - payment_hash, payment_request = await create_invoice( + _, payment_request = await create_invoice( wallet_id=card.wallet, - amount=int(amount) / 1000, + amount=int(int(amount) / 1000), memo=f"Refund {hit_id}", unhashed_description=LnurlPayMetadata( json.dumps([["text/plain", "Refund"]]) diff --git a/lnbits/extensions/boltcards/models.py b/lnbits/extensions/boltcards/models.py index 47ca1df0..5ea4be15 100644 --- a/lnbits/extensions/boltcards/models.py +++ b/lnbits/extensions/boltcards/models.py @@ -1,14 +1,11 @@ +import json from sqlite3 import Row -from typing import Optional -from fastapi import Request -from fastapi.params import Query +from fastapi import Query, Request 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 lnurl import encode as lnurl_encode +from lnurl.types import LnurlPayMetadata from pydantic import BaseModel -from pydantic.main import BaseModel ZERO_KEY = "00000000000000000000000000000000" @@ -32,6 +29,7 @@ class Card(BaseModel): otp: str time: int + @classmethod def from_row(cls, row: Row) -> "Card": return cls(**dict(row)) @@ -40,7 +38,7 @@ class Card(BaseModel): return lnurl_encode(url) async def lnurlpay_metadata(self) -> LnurlPayMetadata: - return LnurlPayMetadata(json.dumps([["text/plain", self.title]])) + return LnurlPayMetadata(json.dumps([["text/plain", self.card_name]])) class CreateCardData(BaseModel): @@ -69,6 +67,7 @@ class Hit(BaseModel): amount: int time: int + @classmethod def from_row(cls, row: Row) -> "Hit": return cls(**dict(row)) @@ -79,5 +78,6 @@ class Refund(BaseModel): refund_amount: int time: int + @classmethod def from_row(cls, row: Row) -> "Refund": return cls(**dict(row)) diff --git a/lnbits/extensions/boltcards/tasks.py b/lnbits/extensions/boltcards/tasks.py index c1e99b76..ab2547af 100644 --- a/lnbits/extensions/boltcards/tasks.py +++ b/lnbits/extensions/boltcards/tasks.py @@ -1,8 +1,6 @@ import asyncio import json -import httpx - from lnbits.core import db as core_db from lnbits.core.models import Payment from lnbits.helpers import get_current_extension_name @@ -21,22 +19,25 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: + if not payment.extra.get("refund"): return if payment.extra.get("wh_status"): # this webhook has already been sent return - hit = await get_hit(payment.extra.get("refund")) + + hit = await get_hit(str(payment.extra.get("refund"))) if hit: - refund = await create_refund( - hit_id=hit.id, refund_amount=(payment.amount / 1000) - ) + await create_refund(hit_id=hit.id, refund_amount=(payment.amount / 1000)) await mark_webhook_sent(payment, 1) async def mark_webhook_sent(payment: Payment, status: int) -> None: + if not payment.extra: + return + payment.extra["wh_status"] = status await core_db.execute( diff --git a/lnbits/extensions/boltcards/views.py b/lnbits/extensions/boltcards/views.py index 8fcbb7de..273cfcbf 100644 --- a/lnbits/extensions/boltcards/views.py +++ b/lnbits/extensions/boltcards/views.py @@ -1,5 +1,4 @@ -from fastapi import FastAPI, Request -from fastapi.params import Depends +from fastapi import Depends, Request from fastapi.templating import Jinja2Templates from starlette.responses import HTMLResponse diff --git a/lnbits/extensions/boltcards/views_api.py b/lnbits/extensions/boltcards/views_api.py index c18c33d0..feca12e0 100644 --- a/lnbits/extensions/boltcards/views_api.py +++ b/lnbits/extensions/boltcards/views_api.py @@ -1,10 +1,6 @@ -import secrets from http import HTTPStatus -from fastapi.params import Depends, Query -from loguru import logger -from starlette.exceptions import HTTPException -from starlette.requests import Request +from fastapi import Depends, HTTPException, Query from lnbits.core.crud import get_user from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key @@ -15,13 +11,11 @@ from .crud import ( delete_card, enable_disable_card, get_card, - get_card_by_otp, get_card_by_uid, get_cards, get_hits, get_refunds, update_card, - update_card_otp, ) from .models import CreateCardData @@ -33,7 +27,8 @@ async def api_cards( wallet_ids = [g.wallet.id] if all_wallets: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] return [card.dict() for card in await get_cards(wallet_ids)] @@ -41,9 +36,8 @@ async def api_cards( @boltcards_ext.post("/api/v1/cards", status_code=HTTPStatus.CREATED) @boltcards_ext.put("/api/v1/cards/{card_id}", status_code=HTTPStatus.OK) async def api_card_create_or_update( - # req: Request, data: CreateCardData, - card_id: str = None, + card_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key), ): try: @@ -95,6 +89,7 @@ async def api_card_create_or_update( status_code=HTTPStatus.BAD_REQUEST, ) card = await create_card(wallet_id=wallet.wallet.id, data=data) + assert card return card.dict() @@ -110,6 +105,7 @@ async def enable_card( if card.wallet != wallet.wallet.id: raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN) card = await enable_disable_card(enable=enable, id=card_id) + assert card return card.dict() @@ -136,7 +132,8 @@ async def api_hits( wallet_ids = [g.wallet.id] if all_wallets: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] cards = await get_cards(wallet_ids) cards_ids = [] @@ -153,15 +150,13 @@ async def api_refunds( wallet_ids = [g.wallet.id] if all_wallets: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] cards = await get_cards(wallet_ids) cards_ids = [] for card in cards: cards_ids.append(card.id) hits = await get_hits(cards_ids) - hits_ids = [] - for hit in hits: - hits_ids.append(hit.id) - return [refund.dict() for refund in await get_refunds(hits_ids)] + return [refund.dict() for refund in await get_refunds(hits)] diff --git a/lnbits/extensions/cashu/tasks.py b/lnbits/extensions/cashu/tasks.py index 9de17a1c..bf49171c 100644 --- a/lnbits/extensions/cashu/tasks.py +++ b/lnbits/extensions/cashu/tasks.py @@ -28,6 +28,7 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if payment.extra and not payment.extra.get("tag") == "cashu": + if payment.extra.get("tag") != "cashu": return + return diff --git a/lnbits/extensions/copilot/tasks.py b/lnbits/extensions/copilot/tasks.py index 384070cd..4975b5a3 100644 --- a/lnbits/extensions/copilot/tasks.py +++ b/lnbits/extensions/copilot/tasks.py @@ -24,12 +24,12 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - webhook = None - data = None - if not payment.extra or payment.extra.get("tag") != "copilot": + if payment.extra.get("tag") != "copilot": # not an copilot invoice return + webhook = None + data = None copilot = await get_copilot(payment.extra.get("copilotid", -1)) if not copilot: diff --git a/lnbits/extensions/gerty/__init__.py b/lnbits/extensions/gerty/__init__.py index bd353c78..5b24718a 100644 --- a/lnbits/extensions/gerty/__init__.py +++ b/lnbits/extensions/gerty/__init__.py @@ -5,11 +5,9 @@ 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_gerty") - gerty_static_files = [ { "path": "/gerty/static", diff --git a/lnbits/extensions/gerty/crud.py b/lnbits/extensions/gerty/crud.py index 2fc0a7c1..5475139c 100644 --- a/lnbits/extensions/gerty/crud.py +++ b/lnbits/extensions/gerty/crud.py @@ -50,11 +50,12 @@ async def create_gerty(wallet_id: str, data: Gerty) -> Gerty: return gerty -async def update_gerty(gerty_id: str, **kwargs) -> Gerty: +async def update_gerty(gerty_id: str, **kwargs) -> Optional[Gerty]: q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) await db.execute( f"UPDATE gerty.gertys SET {q} WHERE id = ?", (*kwargs.values(), gerty_id) ) + return await get_gerty(gerty_id) @@ -82,7 +83,7 @@ async def delete_gerty(gerty_id: str) -> None: #############MEMPOOL########### -async def get_mempool_info(endPoint: str, gerty) -> Optional[Mempool]: +async def get_mempool_info(endPoint: str, gerty) -> dict: logger.debug(endPoint) endpoints = MempoolEndpoint() url = "" diff --git a/lnbits/extensions/gerty/helpers.py b/lnbits/extensions/gerty/helpers.py index 65c69073..3e48c576 100644 --- a/lnbits/extensions/gerty/helpers.py +++ b/lnbits/extensions/gerty/helpers.py @@ -3,15 +3,16 @@ import os import random import textwrap from datetime import datetime, timedelta +from typing import List import httpx from loguru import logger -from lnbits.core.crud import get_user, get_wallet_for_key +from lnbits.core.crud import get_wallet_for_key from lnbits.settings import settings from lnbits.utils.exchange_rates import satoshis_amount_as_fiat -from .crud import get_gerty, get_mempool_info +from .crud import get_mempool_info from .number_prefixer import * @@ -24,8 +25,8 @@ def get_percent_difference(current, previous, precision=3): def get_text_item_dict( text: str, font_size: int, - x_pos: int = None, - y_pos: int = None, + x_pos: int = -1, + y_pos: int = -1, gerty_type: str = "Gerty", ): # Get line size by font size @@ -63,13 +64,41 @@ def get_text_item_dict( # logger.debug('multilineText') # logger.debug(multilineText) - text = {"value": multilineText, "size": font_size} - if x_pos is None and y_pos is None: - text["position"] = "center" + data_text = {"value": multilineText, "size": font_size} + if x_pos == -1 and y_pos == -1: + data_text["position"] = "center" else: - text["x"] = x_pos - text["y"] = y_pos - return text + data_text["x"] = x_pos if x_pos > 0 else 0 + data_text["y"] = y_pos if x_pos > 0 else 0 + return data_text + + +def get_date_suffix(dayNumber): + if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30: + return "th" + else: + return ["st", "nd", "rd"][dayNumber % 10 - 1] + + +def get_time_remaining(seconds, granularity=2): + intervals = ( + # ('weeks', 604800), # 60 * 60 * 24 * 7 + ("days", 86400), # 60 * 60 * 24 + ("hours", 3600), # 60 * 60 + ("minutes", 60), + ("seconds", 1), + ) + + result = [] + + for name, count in intervals: + value = seconds // count + if value: + seconds -= value * count + if value == 1: + name = name.rstrip("s") + result.append("{} {}".format(round(value), name)) + return ", ".join(result[:granularity]) # format a number for nice display output @@ -293,8 +322,7 @@ def get_next_update_time(sleep_time_seconds: int = 0, utc_offset: int = 0): def gerty_should_sleep(utc_offset: int = 0): utc_now = datetime.utcnow() local_time = utc_now + timedelta(hours=utc_offset) - hours = local_time.strftime("%H") - hours = int(hours) + hours = int(local_time.strftime("%H")) if hours >= 22 and hours <= 23: return True else: @@ -352,23 +380,17 @@ async def get_mining_stat(stat_slug: str, gerty): async def api_get_mining_stat(stat_slug: str, gerty): - stat = "" + stat = {} if stat_slug == "mining_current_hash_rate": - async with httpx.AsyncClient() as client: - r = await get_mempool_info("hashrate_1m", gerty) - data = r - stat = {} - stat["current"] = data["currentHashrate"] - stat["1w"] = data["hashrates"][len(data["hashrates"]) - 7]["avgHashrate"] + r = await get_mempool_info("hashrate_1m", gerty) + data = r + stat["current"] = data["currentHashrate"] + stat["1w"] = data["hashrates"][len(data["hashrates"]) - 7]["avgHashrate"] elif stat_slug == "mining_current_difficulty": - async with httpx.AsyncClient() as client: - r = await get_mempool_info("hashrate_1m", gerty) - data = r - stat = {} - stat["current"] = data["currentDifficulty"] - stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2][ - "difficulty" - ] + r = await get_mempool_info("hashrate_1m", gerty) + data = r + stat["current"] = data["currentDifficulty"] + stat["previous"] = data["difficulty"][len(data["difficulty"]) - 2]["difficulty"] return stat @@ -384,7 +406,7 @@ async def get_satoshi(): quote = satoshiQuotes[random.randint(0, len(satoshiQuotes) - 1)] # logger.debug(quote.text) if len(quote["text"]) > maxQuoteLength: - logger.debug("Quote is too long, getting another") + logger.trace("Quote is too long, getting another") return await get_satoshi() else: return quote @@ -399,15 +421,16 @@ def get_screen_slug_by_index(index: int, screens_list): # Get a list of text items for the screen number -async def get_screen_data(screen_num: int, screens_list: dict, gerty): +async def get_screen_data(screen_num: int, screens_list: list, gerty): screen_slug = get_screen_slug_by_index(screen_num, screens_list) # first get the relevant slug from the display_preferences - areas = [] + areas: List = [] title = "" if screen_slug == "dashboard": title = gerty.name areas = await get_dashboard(gerty) + if screen_slug == "lnbits_wallets_balance": wallets = await get_lnbits_wallet_balances(gerty) @@ -505,10 +528,10 @@ async def get_screen_data(screen_num: int, screens_list: dict, gerty): title = "Lightning Network" areas = await get_lightning_stats(gerty) - data = {} - data["title"] = title - data["areas"] = areas - + data = { + "title": title, + "areas": areas, + } return data @@ -570,7 +593,7 @@ async def get_dashboard(gerty): text = [] text.append( get_text_item_dict( - text=await get_time_remaining_next_difficulty_adjustment(gerty), + text=await get_time_remaining_next_difficulty_adjustment(gerty) or "0", font_size=15, gerty_type=gerty.type, ) @@ -602,7 +625,7 @@ async def get_lnbits_wallet_balances(gerty): return wallets -async def get_placeholder_text(): +async def get_placeholder_text(gerty): return [ get_text_item_dict( text="Some placeholder text", @@ -810,14 +833,14 @@ async def get_time_remaining_next_difficulty_adjustment(gerty): r = await get_mempool_info("difficulty_adjustment", gerty) stat = r["remainingTime"] time = get_time_remaining(stat / 1000, 3) - return time + return time async def get_mempool_stat(stat_slug: str, gerty): text = [] if isinstance(gerty.mempool_endpoint, str): if stat_slug == "mempool_tx_count": - r = get_mempool_info("mempool", gerty) + r = await get_mempool_info("mempool", gerty) if stat_slug == "mempool_tx_count": stat = round(r["count"]) text.append( @@ -921,31 +944,3 @@ async def get_mempool_stat(stat_slug: str, gerty): ) ) return text - - -def get_date_suffix(dayNumber): - if 4 <= dayNumber <= 20 or 24 <= dayNumber <= 30: - return "th" - else: - return ["st", "nd", "rd"][dayNumber % 10 - 1] - - -def get_time_remaining(seconds, granularity=2): - intervals = ( - # ('weeks', 604800), # 60 * 60 * 24 * 7 - ("days", 86400), # 60 * 60 * 24 - ("hours", 3600), # 60 * 60 - ("minutes", 60), - ("seconds", 1), - ) - - result = [] - - for name, count in intervals: - value = seconds // count - if value: - seconds -= value * count - if value == 1: - name = name.rstrip("s") - result.append("{} {}".format(round(value), name)) - return ", ".join(result[:granularity]) diff --git a/lnbits/extensions/gerty/models.py b/lnbits/extensions/gerty/models.py index 9ff29bda..cb19c2bc 100644 --- a/lnbits/extensions/gerty/models.py +++ b/lnbits/extensions/gerty/models.py @@ -1,5 +1,4 @@ from sqlite3 import Row -from typing import Optional from fastapi import Query from pydantic import BaseModel diff --git a/lnbits/extensions/gerty/templates/gerty/gerty.html b/lnbits/extensions/gerty/templates/gerty/gerty.html index d45484a4..06a29e22 100644 --- a/lnbits/extensions/gerty/templates/gerty/gerty.html +++ b/lnbits/extensions/gerty/templates/gerty/gerty.html @@ -32,7 +32,10 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %} -
+
- +
Mining
@@ -78,7 +81,12 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
- +
Lightning (Last 7 days)
@@ -88,7 +96,6 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}

-
Servers to check
@@ -153,7 +160,13 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %} lnbits_wallets_balance: {}, dashboard_onchain: {}, fun_satoshi_quotes: {}, - fun_exchange_market_rate: {}, + fun_exchange_market_rate: { + unit: '' + }, + dashboard_mining: {}, + lightning_dashboard: {}, + url_checker: {}, + dashboard_mining: {}, gerty: [], gerty_id: `{{gerty}}`, gertyname: '', @@ -182,7 +195,6 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %} LNbits.utils.notifyApiError(error) } } - console.log(this.gerty) for (let i = 0; i < this.gerty.length; i++) { if (this.gerty[i].screen.group == 'lnbits_wallets_balance') { for (let q = 0; q < this.gerty[i].screen.areas.length; q++) { diff --git a/lnbits/extensions/gerty/views.py b/lnbits/extensions/gerty/views.py index 66194a50..33e95d3e 100644 --- a/lnbits/extensions/gerty/views.py +++ b/lnbits/extensions/gerty/views.py @@ -1,10 +1,7 @@ -import json from http import HTTPStatus -from fastapi import Request -from fastapi.params import Depends +from fastapi import Depends, Request from fastapi.templating import Jinja2Templates -from loguru import logger from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse @@ -13,7 +10,6 @@ from lnbits.decorators import check_user_exists from . import gerty_ext, gerty_renderer from .crud import get_gerty -from .views_api import api_gerty_json templates = Jinja2Templates(directory="templates") diff --git a/lnbits/extensions/gerty/views_api.py b/lnbits/extensions/gerty/views_api.py index 7272fb7d..c408504b 100644 --- a/lnbits/extensions/gerty/views_api.py +++ b/lnbits/extensions/gerty/views_api.py @@ -1,24 +1,12 @@ import json -import math -import os -import random -import time -from datetime import datetime from http import HTTPStatus -import httpx -from fastapi import Query -from fastapi.params import Depends -from fastapi.templating import Jinja2Templates -from lnurl import decode as decode_lnurl +from fastapi import Depends, Query from loguru import logger from starlette.exceptions import HTTPException -from lnbits.core.crud import get_user, get_wallet_for_key -from lnbits.core.services import create_invoice -from lnbits.core.views.api import api_payment, api_wallet +from lnbits.core.crud import get_user from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key -from lnbits.utils.exchange_rates import satoshis_amount_as_fiat from . import gerty_ext from .crud import ( @@ -29,8 +17,14 @@ from .crud import ( get_mempool_info, update_gerty, ) -from .helpers import * -from .models import Gerty, MempoolEndpoint +from .helpers import ( + gerty_should_sleep, + get_next_update_time, + get_satoshi, + get_screen_data, + get_screen_slug_by_index, +) +from .models import Gerty @gerty_ext.get("/api/v1/gerty", status_code=HTTPStatus.OK) @@ -39,7 +33,8 @@ async def api_gertys( ): wallet_ids = [wallet.wallet.id] if all_wallets: - wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] return [gerty.dict() for gerty in await get_gertys(wallet_ids)] @@ -51,7 +46,6 @@ async def api_link_create_or_update( wallet: WalletTypeInfo = Depends(get_key_type), gerty_id: str = Query(None), ): - logger.debug(data) if gerty_id: gerty = await get_gerty(gerty_id) if not gerty: @@ -67,6 +61,9 @@ async def api_link_create_or_update( data.wallet = wallet.wallet.id gerty = await update_gerty(gerty_id, **data.dict()) + assert gerty, HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Gerty does not exist" + ) else: gerty = await create_gerty(wallet_id=wallet.wallet.id, data=data) @@ -93,11 +90,11 @@ async def api_gerty_delete( @gerty_ext.get("/api/v1/gerty/satoshiquote", status_code=HTTPStatus.OK) async def api_gerty_satoshi(): - return await get_satoshi + return await get_satoshi() @gerty_ext.get("/api/v1/gerty/pages/{gerty_id}/{p}") -async def api_gerty_json(gerty_id: str, p: int = None): # page number +async def api_gerty_json(gerty_id: str, p: int = 0): # page number gerty = await get_gerty(gerty_id) if not gerty: @@ -117,7 +114,7 @@ async def api_gerty_json(gerty_id: str, p: int = None): # page number enabled_screen_count += 1 enabled_screens.append(screen_slug) - logger.debug("Screeens " + str(enabled_screens)) + logger.debug("Screens " + str(enabled_screens)) data = await get_screen_data(p, enabled_screens, gerty) next_screen_number = 0 if ((p + 1) >= enabled_screen_count) else p + 1 diff --git a/lnbits/extensions/invoices/crud.py b/lnbits/extensions/invoices/crud.py index 4fd055e9..9a05f9c5 100644 --- a/lnbits/extensions/invoices/crud.py +++ b/lnbits/extensions/invoices/crud.py @@ -6,7 +6,6 @@ from . import db from .models import ( CreateInvoiceData, CreateInvoiceItemData, - CreatePaymentData, Invoice, InvoiceItem, Payment, @@ -30,7 +29,7 @@ async def get_invoice_items(invoice_id: str) -> List[InvoiceItem]: return [InvoiceItem.from_row(row) for row in rows] -async def get_invoice_item(item_id: str) -> InvoiceItem: +async def get_invoice_item(item_id: str) -> Optional[InvoiceItem]: row = await db.fetchone( "SELECT * FROM invoices.invoice_items WHERE id = ?", (item_id,) ) @@ -61,7 +60,7 @@ async def get_invoice_payments(invoice_id: str) -> List[Payment]: return [Payment.from_row(row) for row in rows] -async def get_invoice_payment(payment_id: str) -> Payment: +async def get_invoice_payment(payment_id: str) -> Optional[Payment]: row = await db.fetchone( "SELECT * FROM invoices.payments WHERE id = ?", (payment_id,) ) @@ -120,7 +119,9 @@ async def create_invoice_items( return invoice_items -async def update_invoice_internal(wallet_id: str, data: UpdateInvoiceData) -> Invoice: +async def update_invoice_internal( + wallet_id: str, data: Union[UpdateInvoiceData, Invoice] +) -> Invoice: await db.execute( """ UPDATE invoices.invoices @@ -155,21 +156,21 @@ async def update_invoice_items( updated_items.append(item.id) await db.execute( """ - UPDATE invoices.invoice_items + UPDATE invoices.invoice_items SET description = ?, amount = ? WHERE id = ? """, (item.description, int(item.amount * 100), item.id), ) - placeholders = ",".join("?" for i in range(len(updated_items))) + placeholders = ",".join("?" for _ in range(len(updated_items))) if not placeholders: placeholders = "?" - updated_items = ("skip",) + updated_items = ["skip"] await db.execute( f""" - DELETE FROM invoices.invoice_items + DELETE FROM invoices.invoice_items WHERE invoice_id = ? AND id NOT IN ({placeholders}) """, @@ -180,8 +181,11 @@ async def update_invoice_items( ) for item in data: - if not item.id: - await create_invoice_items(invoice_id=invoice_id, data=[item]) + if not item: + await create_invoice_items( + invoice_id=invoice_id, + data=[CreateInvoiceItemData(description=item.description)], + ) invoice_items = await get_invoice_items(invoice_id) return invoice_items diff --git a/lnbits/extensions/invoices/models.py b/lnbits/extensions/invoices/models.py index adf03e46..6f0e63cb 100644 --- a/lnbits/extensions/invoices/models.py +++ b/lnbits/extensions/invoices/models.py @@ -2,7 +2,7 @@ from enum import Enum from sqlite3 import Row from typing import List, Optional -from fastapi.param_functions import Query +from fastapi import Query from pydantic import BaseModel diff --git a/lnbits/extensions/invoices/tasks.py b/lnbits/extensions/invoices/tasks.py index 61bcb7b4..c8a829db 100644 --- a/lnbits/extensions/invoices/tasks.py +++ b/lnbits/extensions/invoices/tasks.py @@ -1,9 +1,7 @@ import asyncio -import json from lnbits.core.models import Payment -from lnbits.helpers import urlsafe_short_hash -from lnbits.tasks import internal_invoice_queue, register_invoice_listener +from lnbits.tasks import register_invoice_listener from .crud import ( create_invoice_payment, @@ -14,6 +12,7 @@ from .crud import ( get_payments_total, update_invoice_internal, ) +from .models import InvoiceStatusEnum async def wait_for_paid_invoices(): @@ -27,16 +26,18 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: if payment.extra.get("tag") != "invoices": - # not relevant return invoice_id = payment.extra.get("invoice_id") + assert invoice_id - payment = await create_invoice_payment( - invoice_id=invoice_id, amount=payment.extra.get("famount") - ) + amount = payment.extra.get("famount") + assert amount + + await create_invoice_payment(invoice_id=invoice_id, amount=amount) invoice = await get_invoice(invoice_id) + assert invoice invoice_items = await get_invoice_items(invoice_id) invoice_total = await get_invoice_total(invoice_items) @@ -45,7 +46,7 @@ async def on_invoice_paid(payment: Payment) -> None: payments_total = await get_payments_total(invoice_payments) if payments_total >= invoice_total: - invoice.status = "paid" + invoice.status = InvoiceStatusEnum.paid await update_invoice_internal(invoice.wallet, invoice) return diff --git a/lnbits/extensions/invoices/templates/invoices/pay.html b/lnbits/extensions/invoices/templates/invoices/pay.html index 636dbc90..82f1765e 100644 --- a/lnbits/extensions/invoices/templates/invoices/pay.html +++ b/lnbits/extensions/invoices/templates/invoices/pay.html @@ -257,7 +257,7 @@ block page %} > diff --git a/lnbits/extensions/invoices/views.py b/lnbits/extensions/invoices/views.py index b492a67c..cc35b351 100644 --- a/lnbits/extensions/invoices/views.py +++ b/lnbits/extensions/invoices/views.py @@ -1,10 +1,8 @@ from datetime import datetime from http import HTTPStatus -from fastapi import FastAPI, Request -from fastapi.params import Depends +from fastapi import Depends, HTTPException, Request from fastapi.templating import Jinja2Templates -from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse from lnbits.core.models import User diff --git a/lnbits/extensions/invoices/views_api.py b/lnbits/extensions/invoices/views_api.py index 23a262e3..1a7762a8 100644 --- a/lnbits/extensions/invoices/views_api.py +++ b/lnbits/extensions/invoices/views_api.py @@ -1,14 +1,12 @@ from http import HTTPStatus -from fastapi import Query -from fastapi.params import Depends +from fastapi import Depends, HTTPException, Query from loguru import logger -from starlette.exceptions import HTTPException from lnbits.core.crud import get_user from lnbits.core.services import create_invoice from lnbits.core.views.api import api_payment -from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key +from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.utils.exchange_rates import fiat_amount_as_satoshis from . import invoices_ext @@ -33,7 +31,8 @@ async def api_invoices( ): wallet_ids = [wallet.wallet.id] if all_wallets: - wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] return [invoice.dict() for invoice in await get_invoices(wallet_ids)] @@ -83,9 +82,7 @@ async def api_invoice_update( @invoices_ext.post( "/api/v1/invoice/{invoice_id}/payments", status_code=HTTPStatus.CREATED ) -async def api_invoices_create_payment( - famount: int = Query(..., ge=1), invoice_id: str = None -): +async def api_invoices_create_payment(invoice_id: str, famount: int = Query(..., ge=1)): invoice = await get_invoice(invoice_id) invoice_items = await get_invoice_items(invoice_id) invoice_total = await get_invoice_total(invoice_items) diff --git a/lnbits/extensions/jukebox/tasks.py b/lnbits/extensions/jukebox/tasks.py index 8a68fd27..37489edb 100644 --- a/lnbits/extensions/jukebox/tasks.py +++ b/lnbits/extensions/jukebox/tasks.py @@ -17,8 +17,8 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if payment.extra: - if payment.extra.get("tag") != "jukebox": - # not a jukebox invoice - return - await update_jukebox_payment(payment.payment_hash, paid=True) + if payment.extra.get("tag") != "jukebox": + # not a jukebox invoice + return + + await update_jukebox_payment(payment.payment_hash, paid=True) diff --git a/lnbits/extensions/lnaddress/cloudflare.py b/lnbits/extensions/lnaddress/cloudflare.py index 981a37b0..679cb515 100644 --- a/lnbits/extensions/lnaddress/cloudflare.py +++ b/lnbits/extensions/lnaddress/cloudflare.py @@ -16,7 +16,7 @@ async def cloudflare_create_record(domain: Domains, ip: str): "Content-Type": "application/json", } - cf_response = "" + cf_response = {} async with httpx.AsyncClient() as client: try: r = await client.post( @@ -31,9 +31,9 @@ async def cloudflare_create_record(domain: Domains, ip: str): }, timeout=40, ) - cf_response = json.loads(r.text) + cf_response = r.json() except AssertionError: - cf_response = "Error occured" + cf_response = {"error": "Error occured"} return cf_response @@ -53,3 +53,4 @@ async def cloudflare_deleterecord(domain: Domains, domain_id: str): cf_response = r.text except AssertionError: cf_response = "Error occured" + return cf_response diff --git a/lnbits/extensions/lnaddress/crud.py b/lnbits/extensions/lnaddress/crud.py index 25338215..0e590ec8 100644 --- a/lnbits/extensions/lnaddress/crud.py +++ b/lnbits/extensions/lnaddress/crud.py @@ -128,6 +128,7 @@ async def get_addresses(wallet_ids: Union[str, List[str]]) -> List[Addresses]: async def set_address_paid(payment_hash: str) -> Addresses: address = await get_address(payment_hash) + assert address if address.paid == False: await db.execute( @@ -146,6 +147,7 @@ async def set_address_paid(payment_hash: str) -> Addresses: async def set_address_renewed(address_id: str, duration: int): address = await get_address(address_id) + assert address extend_duration = int(address.duration) + duration await db.execute( diff --git a/lnbits/extensions/lnaddress/lnurl.py b/lnbits/extensions/lnaddress/lnurl.py index 6f799439..c4c3cea5 100644 --- a/lnbits/extensions/lnaddress/lnurl.py +++ b/lnbits/extensions/lnaddress/lnurl.py @@ -1,17 +1,9 @@ -import hashlib -import json from datetime import datetime, timedelta import httpx -from fastapi.params import Query -from lnurl import ( # type: ignore - LnurlErrorResponse, - LnurlPayActionResponse, - LnurlPayResponse, -) +from fastapi import Query, Request +from lnurl import LnurlErrorResponse from loguru import logger -from starlette.requests import Request -from starlette.responses import HTMLResponse from . import lnaddress_ext from .crud import get_address, get_address_by_username, get_domain @@ -52,6 +44,7 @@ async def lnurl_callback(address_id, amount: int = Query(...)): amount_received = amount domain = await get_domain(address.domain) + assert domain base_url = ( address.wallet_endpoint[:-1] @@ -79,7 +72,7 @@ async def lnurl_callback(address_id, amount: int = Query(...)): ) r = call.json() - except AssertionError as e: + except Exception: return LnurlErrorResponse(reason="ERROR") # resp = LnurlPayActionResponse(pr=r["payment_request"], routes=[]) diff --git a/lnbits/extensions/lnaddress/models.py b/lnbits/extensions/lnaddress/models.py index 248f856c..77eb3cd3 100644 --- a/lnbits/extensions/lnaddress/models.py +++ b/lnbits/extensions/lnaddress/models.py @@ -1,9 +1,9 @@ import json from typing import Optional -from fastapi.params import Query +from fastapi import Query from lnurl.types import LnurlPayMetadata -from pydantic.main import BaseModel +from pydantic import BaseModel class CreateDomain(BaseModel): diff --git a/lnbits/extensions/lnaddress/tasks.py b/lnbits/extensions/lnaddress/tasks.py index 0c377eec..3699c463 100644 --- a/lnbits/extensions/lnaddress/tasks.py +++ b/lnbits/extensions/lnaddress/tasks.py @@ -1,6 +1,7 @@ import asyncio import httpx +from loguru import logger from lnbits.core.models import Payment from lnbits.helpers import get_current_extension_name @@ -21,7 +22,9 @@ async def wait_for_paid_invoices(): async def call_webhook_on_paid(payment_hash): ### Use webhook to notify about cloudflare registration address = await get_address(payment_hash) + assert address domain = await get_domain(address.domain) + assert domain if not domain.webhook: return @@ -39,24 +42,23 @@ async def call_webhook_on_paid(payment_hash): }, timeout=40, ) - except AssertionError: - webhook = None + r.raise_for_status() + except Exception as e: + logger.error(f"lnaddress: error calling webhook on paid: {str(e)}") async def on_invoice_paid(payment: Payment) -> None: - if payment.extra.get("tag") == "lnaddress": + if payment.extra.get("tag") == "lnaddress": await payment.set_pending(False) await set_address_paid(payment_hash=payment.payment_hash) await call_webhook_on_paid(payment_hash=payment.payment_hash) elif payment.extra.get("tag") == "renew lnaddress": - await payment.set_pending(False) await set_address_renewed( address_id=payment.extra["id"], duration=payment.extra["duration"] ) await call_webhook_on_paid(payment_hash=payment.payment_hash) - else: return diff --git a/lnbits/extensions/lnaddress/views.py b/lnbits/extensions/lnaddress/views.py index 8c838f0c..d1a7be83 100644 --- a/lnbits/extensions/lnaddress/views.py +++ b/lnbits/extensions/lnaddress/views.py @@ -1,10 +1,8 @@ from http import HTTPStatus from urllib.parse import urlparse -from fastapi import Request -from fastapi.params import Depends +from fastapi import Depends, HTTPException, Request from fastapi.templating import Jinja2Templates -from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse from lnbits.core.crud import get_wallet @@ -35,6 +33,7 @@ async def display(domain_id, request: Request): await purge_addresses(domain_id) wallet = await get_wallet(domain.wallet) + assert wallet url = urlparse(str(request.url)) return lnaddress_renderer().TemplateResponse( diff --git a/lnbits/extensions/lnaddress/views_api.py b/lnbits/extensions/lnaddress/views_api.py index 46ef6b99..d9e50e9d 100644 --- a/lnbits/extensions/lnaddress/views_api.py +++ b/lnbits/extensions/lnaddress/views_api.py @@ -1,9 +1,7 @@ from http import HTTPStatus from urllib.parse import urlparse -from fastapi import Request -from fastapi.params import Depends, Query -from starlette.exceptions import HTTPException +from fastapi import Depends, HTTPException, Query, Request from lnbits.core.crud import get_user from lnbits.core.services import check_transaction_status, create_invoice @@ -11,7 +9,7 @@ from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain from . import lnaddress_ext -from .cloudflare import cloudflare_create_record, cloudflare_deleterecord +from .cloudflare import cloudflare_create_record from .crud import ( check_address_available, create_address, @@ -35,7 +33,8 @@ async def api_domains( wallet_ids = [g.wallet.id] if all_wallets: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] return [domain.dict() for domain in await get_domains(wallet_ids)] @@ -69,7 +68,7 @@ async def api_domain_create( cf_response = await cloudflare_create_record(domain=domain, ip=root_url) - if not cf_response or cf_response["success"] != True: + if not cf_response or not cf_response["success"]: await delete_domain(domain.id) raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, @@ -106,7 +105,8 @@ async def api_addresses( wallet_ids = [g.wallet.id] if all_wallets: - wallet_ids = (await get_user(g.wallet.user)).wallet_ids + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] return [address.dict() for address in await get_addresses(wallet_ids)] @@ -227,7 +227,9 @@ async def api_lnaddress_make_address( @lnaddress_ext.get("/api/v1/addresses/{payment_hash}") async def api_address_send_address(payment_hash): address = await get_address(payment_hash) + assert address domain = await get_domain(address.domain) + assert domain try: status = await check_transaction_status(domain.wallet, payment_hash) is_paid = not status.pending diff --git a/lnbits/extensions/lndhub/views.py b/lnbits/extensions/lndhub/views.py index 38a33a34..b216f8b1 100644 --- a/lnbits/extensions/lndhub/views.py +++ b/lnbits/extensions/lndhub/views.py @@ -1,5 +1,4 @@ -from fastapi import Request -from fastapi.params import Depends +from fastapi import Depends, Request from lnbits.core.models import User from lnbits.decorators import check_user_exists diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py index c21c0bfd..1dff5235 100644 --- a/lnbits/extensions/lndhub/views_api.py +++ b/lnbits/extensions/lndhub/views_api.py @@ -1,15 +1,13 @@ -import asyncio import time from base64 import urlsafe_b64encode from http import HTTPStatus -from fastapi.param_functions import Query -from fastapi.params import Depends +from fastapi import Depends, Query from pydantic import BaseModel from starlette.exceptions import HTTPException from lnbits import bolt11 -from lnbits.core.crud import delete_expired_invoices, get_payments +from lnbits.core.crud import get_payments from lnbits.core.services import create_invoice, pay_invoice from lnbits.decorators import WalletTypeInfo from lnbits.settings import get_wallet_class, settings @@ -73,13 +71,13 @@ async def lndhub_addinvoice( } -class Invoice(BaseModel): +class CreateInvoice(BaseModel): invoice: str = Query(...) @lndhub_ext.post("/ext/payinvoice") async def lndhub_payinvoice( - r_invoice: Invoice, wallet: WalletTypeInfo = Depends(require_admin_key) + r_invoice: CreateInvoice, wallet: WalletTypeInfo = Depends(require_admin_key) ): try: await pay_invoice( diff --git a/lnbits/extensions/lnticket/tasks.py b/lnbits/extensions/lnticket/tasks.py index e84a7512..746ebea9 100644 --- a/lnbits/extensions/lnticket/tasks.py +++ b/lnbits/extensions/lnticket/tasks.py @@ -19,7 +19,7 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if not payment.extra or payment.extra.get("tag") != "lnticket": + if payment.extra.get("tag") != "lnticket": # not a lnticket invoice return diff --git a/lnbits/extensions/lnurldevice/crud.py b/lnbits/extensions/lnurldevice/crud.py index 18451848..423c6a46 100644 --- a/lnbits/extensions/lnurldevice/crud.py +++ b/lnbits/extensions/lnurldevice/crud.py @@ -1,5 +1,7 @@ from typing import List, Optional, Union +import shortuuid + from lnbits.helpers import urlsafe_short_hash from . import db @@ -12,7 +14,7 @@ async def create_lnurldevice( data: createLnurldevice, ) -> lnurldevices: if data.device == "pos" or data.device == "atm": - lnurldevice_id = str(await get_lnurldeviceposcount()) + lnurldevice_id = shortuuid.uuid()[:5] else: lnurldevice_id = urlsafe_short_hash() lnurldevice_key = urlsafe_short_hash() @@ -82,17 +84,6 @@ async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldev return lnurldevices(**row) if row else None -async def get_lnurldeviceposcount() -> int: - row = await db.fetchall( - "SELECT * FROM lnurldevice.lnurldevices WHERE device = ? OR device = ?", - ( - "pos", - "atm", - ), - ) - return len(row) + 1 - - async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices: row = await db.fetchone( "SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,) diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py index d02ae80e..d5963b95 100644 --- a/lnbits/extensions/lnurlp/crud.py +++ b/lnbits/extensions/lnurlp/crud.py @@ -1,19 +1,18 @@ from typing import List, Optional, Union -from lnbits.db import SQLITE +from lnbits.helpers import urlsafe_short_hash from . import db from .models import CreatePayLinkData, PayLink async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: + link_id = urlsafe_short_hash()[:6] - returning = "" if db.type == SQLITE else "RETURNING ID" - method = db.execute if db.type == SQLITE else db.fetchone - - result = await (method)( + result = await db.execute( f""" INSERT INTO lnurlp.pay_links ( + id, wallet, description, min, @@ -29,10 +28,10 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: currency, fiat_base_multiplier ) - VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?) - {returning} + VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?) """, ( + link_id, wallet_id, data.description, data.min, @@ -47,17 +46,13 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: data.fiat_base_multiplier, ), ) - if db.type == SQLITE: - link_id = result._result_proxy.lastrowid - else: - link_id = result[0] link = await get_pay_link(link_id) assert link, "Newly created link couldn't be retrieved" return link -async def get_pay_link(link_id: int) -> Optional[PayLink]: +async def get_pay_link(link_id: str) -> Optional[PayLink]: row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) return PayLink.from_row(row) if row else None diff --git a/lnbits/extensions/lnurlp/migrations.py b/lnbits/extensions/lnurlp/migrations.py index c4edd3aa..44df5ba9 100644 --- a/lnbits/extensions/lnurlp/migrations.py +++ b/lnbits/extensions/lnurlp/migrations.py @@ -68,3 +68,76 @@ async def m005_webhook_headers_and_body(db): """ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_headers TEXT;") await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_body TEXT;") + + +async def m006_redux(db): + """ + Add UUID ID's to links and migrates existing data + """ + await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old") + await db.execute( + f""" + CREATE TABLE lnurlp.pay_links ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + description TEXT NOT NULL, + min INTEGER NOT NULL, + max INTEGER, + currency TEXT, + fiat_base_multiplier INTEGER DEFAULT 1, + served_meta INTEGER NOT NULL, + served_pr INTEGER NOT NULL, + webhook_url TEXT, + success_text TEXT, + success_url TEXT, + comment_chars INTEGER DEFAULT 0, + webhook_headers TEXT, + webhook_body TEXT + ); + """ + ) + + for row in [ + list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old") + ]: + await db.execute( + """ + INSERT INTO lnurlp.pay_links ( + id, + wallet, + description, + min, + served_meta, + served_pr, + webhook_url, + success_text, + success_url, + currency, + comment_chars, + max, + fiat_base_multiplier, + webhook_headers, + webhook_body + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + row[0], + row[1], + row[2], + row[3], + row[4], + row[5], + row[6], + row[7], + row[8], + row[9], + row[10], + row[11], + row[12], + row[13], + row[14], + ), + ) + + await db.execute("DROP TABLE lnurlp.pay_links_old") diff --git a/lnbits/extensions/lnurlp/models.py b/lnbits/extensions/lnurlp/models.py index 2cb4d0ab..42ea2926 100644 --- a/lnbits/extensions/lnurlp/models.py +++ b/lnbits/extensions/lnurlp/models.py @@ -26,7 +26,7 @@ class CreatePayLinkData(BaseModel): class PayLink(BaseModel): - id: int + id: str wallet: str description: str min: float diff --git a/lnbits/extensions/lnurlp/tasks.py b/lnbits/extensions/lnurlp/tasks.py index b8da5e43..ea01e04f 100644 --- a/lnbits/extensions/lnurlp/tasks.py +++ b/lnbits/extensions/lnurlp/tasks.py @@ -4,7 +4,6 @@ import json import httpx from loguru import logger -from lnbits.core import db as core_db from lnbits.core.crud import update_payment_extra from lnbits.core.models import Payment from lnbits.helpers import get_current_extension_name @@ -22,9 +21,8 @@ async def wait_for_paid_invoices(): await on_invoice_paid(payment) -async def on_invoice_paid(payment: Payment) -> None: +async def on_invoice_paid(payment: Payment): if payment.extra.get("tag") != "lnurlp": - # not an lnurlp invoice return if payment.extra.get("wh_status"): @@ -35,22 +33,23 @@ async def on_invoice_paid(payment: Payment) -> None: if pay_link and pay_link.webhook_url: async with httpx.AsyncClient() as client: try: - kwargs = { - "json": { + r: httpx.Response = await client.post( + pay_link.webhook_url, + json={ "payment_hash": payment.payment_hash, "payment_request": payment.bolt11, "amount": payment.amount, "comment": payment.extra.get("comment"), "lnurlp": pay_link.id, + "body": json.loads(pay_link.webhook_body) + if pay_link.webhook_body + else "", }, - "timeout": 40, - } - if pay_link.webhook_body: - kwargs["json"]["body"] = json.loads(pay_link.webhook_body) - if pay_link.webhook_headers: - kwargs["headers"] = json.loads(pay_link.webhook_headers) - - r: httpx.Response = await client.post(pay_link.webhook_url, **kwargs) + headers=json.loads(pay_link.webhook_headers) + if pay_link.webhook_headers + else None, + timeout=40, + ) await mark_webhook_sent( payment.payment_hash, r.status_code, diff --git a/lnbits/extensions/lnurlp/views.py b/lnbits/extensions/lnurlp/views.py index 4e9f487c..9bc78056 100644 --- a/lnbits/extensions/lnurlp/views.py +++ b/lnbits/extensions/lnurlp/views.py @@ -1,7 +1,6 @@ from http import HTTPStatus -from fastapi import Request -from fastapi.params import Depends +from fastapi import Depends, Request from fastapi.templating import Jinja2Templates from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py index d5966bf6..0fa739b0 100644 --- a/lnbits/extensions/lnurlp/views_api.py +++ b/lnbits/extensions/lnurlp/views_api.py @@ -1,9 +1,7 @@ import json from http import HTTPStatus -from fastapi import Request -from fastapi.param_functions import Query -from fastapi.params import Depends +from fastapi import Depends, Query, Request from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore from starlette.exceptions import HTTPException @@ -36,7 +34,8 @@ async def api_links( wallet_ids = [wallet.wallet.id] if all_wallets: - wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] try: return [ @@ -137,6 +136,7 @@ async def api_link_create_or_update( link = await update_pay_link(**data.dict(), link_id=link_id) else: link = await create_pay_link(data, wallet_id=wallet.wallet.id) + assert link return {**link.dict(), "lnurl": link.lnurl(request)} diff --git a/lnbits/extensions/lnurlpayout/README.md b/lnbits/extensions/lnurlpayout/README.md deleted file mode 100644 index ddf209fe..00000000 --- a/lnbits/extensions/lnurlpayout/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# LNURLPayOut - -## Auto-dump a wallets funds to an LNURLpay diff --git a/lnbits/extensions/lnurlpayout/__init__.py b/lnbits/extensions/lnurlpayout/__init__.py deleted file mode 100644 index 9962290c..00000000 --- a/lnbits/extensions/lnurlpayout/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -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_lnurlpayout") - -lnurlpayout_ext: APIRouter = APIRouter(prefix="/lnurlpayout", tags=["lnurlpayout"]) - - -def lnurlpayout_renderer(): - return template_renderer(["lnbits/extensions/lnurlpayout/templates"]) - - -from .tasks import wait_for_paid_invoices -from .views import * # noqa -from .views_api import * # noqa - - -def lnurlpayout_start(): - loop = asyncio.get_event_loop() - loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/lnurlpayout/config.json.example b/lnbits/extensions/lnurlpayout/config.json.example deleted file mode 100644 index b4160d7b..00000000 --- a/lnbits/extensions/lnurlpayout/config.json.example +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "LNURLPayout", - "short_description": "Autodump wallet funds to LNURLpay", - "icon": "exit_to_app", - "contributors": ["arcbtc","talvasconcelos"] -} diff --git a/lnbits/extensions/lnurlpayout/crud.py b/lnbits/extensions/lnurlpayout/crud.py deleted file mode 100644 index 0f9f98ac..00000000 --- a/lnbits/extensions/lnurlpayout/crud.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import List, Optional, Union - -from lnbits.helpers import urlsafe_short_hash - -from . import db -from .models import CreateLnurlPayoutData, lnurlpayout - - -async def create_lnurlpayout( - wallet_id: str, admin_key: str, data: CreateLnurlPayoutData -) -> lnurlpayout: - lnurlpayout_id = urlsafe_short_hash() - await db.execute( - """ - INSERT INTO lnurlpayout.lnurlpayouts (id, title, wallet, admin_key, lnurlpay, threshold) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - lnurlpayout_id, - data.title, - wallet_id, - admin_key, - data.lnurlpay, - data.threshold, - ), - ) - - lnurlpayout = await get_lnurlpayout(lnurlpayout_id) - assert lnurlpayout, "Newly created lnurlpayout couldn't be retrieved" - return lnurlpayout - - -async def get_lnurlpayout(lnurlpayout_id: str) -> Optional[lnurlpayout]: - row = await db.fetchone( - "SELECT * FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,) - ) - return lnurlpayout(**row) if row else None - - -async def get_lnurlpayout_from_wallet(wallet_id: str) -> Optional[lnurlpayout]: - row = await db.fetchone( - "SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet = ?", (wallet_id,) - ) - return lnurlpayout(**row) if row else None - - -async def get_lnurlpayouts(wallet_ids: Union[str, List[str]]) -> List[lnurlpayout]: - if isinstance(wallet_ids, str): - wallet_ids = [wallet_ids] - - q = ",".join(["?"] * len(wallet_ids)) - rows = await db.fetchall( - f"SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet IN ({q})", (*wallet_ids,) - ) - - return [lnurlpayout(**row) if row else None for row in rows] - - -async def delete_lnurlpayout(lnurlpayout_id: str) -> None: - await db.execute( - "DELETE FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,) - ) diff --git a/lnbits/extensions/lnurlpayout/migrations.py b/lnbits/extensions/lnurlpayout/migrations.py deleted file mode 100644 index 7a45e495..00000000 --- a/lnbits/extensions/lnurlpayout/migrations.py +++ /dev/null @@ -1,16 +0,0 @@ -async def m001_initial(db): - """ - Initial lnurlpayouts table. - """ - await db.execute( - f""" - CREATE TABLE lnurlpayout.lnurlpayouts ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - wallet TEXT NOT NULL, - admin_key TEXT NOT NULL, - lnurlpay TEXT NOT NULL, - threshold {db.big_int} NOT NULL - ); - """ - ) diff --git a/lnbits/extensions/lnurlpayout/models.py b/lnbits/extensions/lnurlpayout/models.py deleted file mode 100644 index fc8be575..00000000 --- a/lnbits/extensions/lnurlpayout/models.py +++ /dev/null @@ -1,18 +0,0 @@ -from sqlite3 import Row - -from pydantic import BaseModel - - -class CreateLnurlPayoutData(BaseModel): - title: str - lnurlpay: str - threshold: int - - -class lnurlpayout(BaseModel): - id: str - title: str - wallet: str - admin_key: str - lnurlpay: str - threshold: int diff --git a/lnbits/extensions/lnurlpayout/tasks.py b/lnbits/extensions/lnurlpayout/tasks.py deleted file mode 100644 index 71f299be..00000000 --- a/lnbits/extensions/lnurlpayout/tasks.py +++ /dev/null @@ -1,91 +0,0 @@ -import asyncio -from http import HTTPStatus - -import httpx -from loguru import logger -from starlette.exceptions import HTTPException - -from lnbits.core import db as core_db -from lnbits.core.crud import get_wallet -from lnbits.core.models import Payment -from lnbits.core.services import pay_invoice -from lnbits.core.views.api import api_payments_decode -from lnbits.helpers import get_current_extension_name -from lnbits.tasks import register_invoice_listener - -from .crud import get_lnurlpayout_from_wallet - - -async def wait_for_paid_invoices(): - invoice_queue = asyncio.Queue() - register_invoice_listener(invoice_queue, get_current_extension_name()) - - while True: - payment = await invoice_queue.get() - await on_invoice_paid(payment) - - -async def on_invoice_paid(payment: Payment) -> None: - try: - # Check its got a payout associated with it - lnurlpayout_link = await get_lnurlpayout_from_wallet(payment.wallet_id) - logger.debug("LNURLpayout", lnurlpayout_link) - if lnurlpayout_link: - - # Check the wallet balance is more than the threshold - - wallet = await get_wallet(lnurlpayout_link.wallet) - threshold = lnurlpayout_link.threshold + (lnurlpayout_link.threshold * 0.02) - - if wallet.balance < threshold: - return - # Get the invoice from the LNURL to pay - async with httpx.AsyncClient() as client: - try: - url = await api_payments_decode({"data": lnurlpayout_link.lnurlpay}) - if str(url["domain"])[0:4] != "http": - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="LNURL broken" - ) - - try: - r = await client.get(str(url["domain"]), timeout=40) - res = r.json() - try: - r = await client.get( - res["callback"] - + "?amount=" - + str( - int((wallet.balance - wallet.balance * 0.02) * 1000) - ), - timeout=40, - ) - res = r.json() - - if hasattr(res, "status") and res["status"] == "ERROR": - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail=res["reason"], - ) - try: - await pay_invoice( - wallet_id=payment.wallet_id, - payment_request=res["pr"], - extra={"tag": "lnurlpayout"}, - ) - return - except: - pass - - except Exception as e: - print("ERROR", str(e)) - return - except (httpx.ConnectError, httpx.RequestError): - return - except Exception: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Failed to save LNURLPayout", - ) - except: - return diff --git a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html deleted file mode 100644 index afe24c42..00000000 --- a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - GET - /lnurlpayout/api/v1/lnurlpayouts -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [<lnurlpayout_object>, ...] -
Curl example
- curl -X GET {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -H - "X-Api-Key: <invoice_key>" - -
-
-
- - - - POST - /lnurlpayout/api/v1/lnurlpayouts -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
- {"name": <string>, "currency": <string*ie USD*>} -
- Returns 201 CREATED (application/json) -
- {"currency": <string>, "id": <string>, "name": - <string>, "wallet": <string>} -
Curl example
- curl -X POST {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -d - '{"name": <string>, "currency": <string>}' -H - "Content-type: application/json" -H "X-Api-Key: <admin_key>" - -
-
-
- - - - - DELETE - /lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -
Headers
- {"X-Api-Key": <admin_key>}
-
Returns 204 NO CONTENT
- -
Curl example
- curl -X DELETE {{ request.base_url - }}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H - "X-Api-Key: <admin_key>" - -
-
-
- - - - GET - /lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- [<lnurlpayout_object>, ...] -
Curl example
- curl -X GET {{ request.base_url - }}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H - "X-Api-Key: <invoice_key>" - -
-
-
-
diff --git a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/index.html b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/index.html deleted file mode 100644 index 98230949..00000000 --- a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/index.html +++ /dev/null @@ -1,271 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - - New LNURLPayout - - - - - -
-
-
LNURLPayout
-
-
- Export to CSV -
-
- - {% raw %} - - - - {% endraw %} - -
-
-
- -
- - -
- {{SITE_TITLE}} LNURLPayout extension -
-
- - - - {% include "lnurlpayout/_api_docs.html" %} - - - -
-
- - - - - - - - -
- Create LNURLPayout - Cancel -
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - -{% endblock %} diff --git a/lnbits/extensions/lnurlpayout/views.py b/lnbits/extensions/lnurlpayout/views.py deleted file mode 100644 index 454a3332..00000000 --- a/lnbits/extensions/lnurlpayout/views.py +++ /dev/null @@ -1,22 +0,0 @@ -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 -from lnbits.decorators import check_user_exists - -from . import lnurlpayout_ext, lnurlpayout_renderer -from .crud import get_lnurlpayout - -templates = Jinja2Templates(directory="templates") - - -@lnurlpayout_ext.get("/", response_class=HTMLResponse) -async def index(request: Request, user: User = Depends(check_user_exists)): - return lnurlpayout_renderer().TemplateResponse( - "lnurlpayout/index.html", {"request": request, "user": user.dict()} - ) diff --git a/lnbits/extensions/lnurlpayout/views_api.py b/lnbits/extensions/lnurlpayout/views_api.py deleted file mode 100644 index 324eb5dd..00000000 --- a/lnbits/extensions/lnurlpayout/views_api.py +++ /dev/null @@ -1,118 +0,0 @@ -from http import HTTPStatus - -from fastapi import Query -from fastapi.params import Depends -from starlette.exceptions import HTTPException - -from lnbits.core.crud import get_payments, get_user -from lnbits.core.models import Payment -from lnbits.core.services import create_invoice -from lnbits.core.views.api import api_payment, api_payments_decode -from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key - -from . import lnurlpayout_ext -from .crud import ( - create_lnurlpayout, - delete_lnurlpayout, - get_lnurlpayout, - get_lnurlpayout_from_wallet, - get_lnurlpayouts, -) -from .models import CreateLnurlPayoutData, lnurlpayout -from .tasks import on_invoice_paid - - -@lnurlpayout_ext.get("/api/v1/lnurlpayouts", status_code=HTTPStatus.OK) -async def api_lnurlpayouts( - all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type) -): - wallet_ids = [wallet.wallet.id] - if all_wallets: - wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids - - return [lnurlpayout.dict() for lnurlpayout in await get_lnurlpayouts(wallet_ids)] - - -@lnurlpayout_ext.post("/api/v1/lnurlpayouts", status_code=HTTPStatus.CREATED) -async def api_lnurlpayout_create( - data: CreateLnurlPayoutData, wallet: WalletTypeInfo = Depends(get_key_type) -): - if await get_lnurlpayout_from_wallet(wallet.wallet.id): - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, - detail="Wallet already has lnurlpayout set", - ) - return - url = await api_payments_decode({"data": data.lnurlpay}) - if "domain" not in url: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="LNURL could not be decoded" - ) - return - if str(url["domain"])[0:4] != "http": - raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not valid LNURL") - return - lnurlpayout = await create_lnurlpayout( - wallet_id=wallet.wallet.id, admin_key=wallet.wallet.adminkey, data=data - ) - if not lnurlpayout: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout" - ) - return - return lnurlpayout.dict() - - -@lnurlpayout_ext.delete("/api/v1/lnurlpayouts/{lnurlpayout_id}") -async def api_lnurlpayout_delete( - lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) -): - lnurlpayout = await get_lnurlpayout(lnurlpayout_id) - - if not lnurlpayout: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="lnurlpayout does not exist." - ) - - if lnurlpayout.wallet != wallet.wallet.id: - raise HTTPException( - status_code=HTTPStatus.FORBIDDEN, detail="Not your lnurlpayout." - ) - - await delete_lnurlpayout(lnurlpayout_id) - return "", HTTPStatus.NO_CONTENT - - -@lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK) -async def api_lnurlpayout_check( - lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(get_key_type) -): - lnurlpayout = await get_lnurlpayout(lnurlpayout_id) - ## THIS - mock_payment = Payment( - checking_id="mock", - pending=False, - amount=1, - fee=1, - time=0000, - bolt11="mock", - preimage="mock", - payment_hash="mock", - wallet_id=lnurlpayout.wallet, - ) - ## INSTEAD OF THIS - # payments = await get_payments( - # wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True - # ) - - result = await on_invoice_paid(mock_payment) - return - - -# get payouts func -# lnurlpayouts = await get_lnurlpayouts(wallet_ids) -# for lnurlpayout in lnurlpayouts: -# payments = await get_payments( -# wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True -# ) -# await on_invoice_paid(payments[0]) diff --git a/lnbits/extensions/market/README.md b/lnbits/extensions/market/README.md new file mode 100644 index 00000000..22d38e0d --- /dev/null +++ b/lnbits/extensions/market/README.md @@ -0,0 +1,9 @@ +

Market

+

A movable market stand

+Make a list of products to sell, point the list to an relay (or many), stack sats. +Market is a movable market stand, for anon transactions. You then give permission for an relay to list those products. Delivery addresses are sent through the Lightning Network. + + +

API endpoints

+ +curl -X GET http://YOUR-TOR-ADDRESS diff --git a/lnbits/extensions/market/__init__.py b/lnbits/extensions/market/__init__.py new file mode 100644 index 00000000..3795ec73 --- /dev/null +++ b/lnbits/extensions/market/__init__.py @@ -0,0 +1,43 @@ +import asyncio + +from fastapi import APIRouter +from starlette.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_market") + +market_ext: APIRouter = APIRouter(prefix="/market", tags=["market"]) + +market_static_files = [ + { + "path": "/market/static", + "app": StaticFiles(directory="lnbits/extensions/market/static"), + "name": "market_static", + } +] + +# if 'nostradmin' not in LNBITS_ADMIN_EXTENSIONS: +# @market_ext.get("/", response_class=HTMLResponse) +# async def index(request: Request): +# return template_renderer().TemplateResponse( +# "error.html", {"request": request, "err": "Ask system admin to enable NostrAdmin!"} +# ) +# else: + + +def market_renderer(): + return template_renderer(["lnbits/extensions/market/templates"]) + # return template_renderer(["lnbits/extensions/market/templates"]) + + +from .tasks import wait_for_paid_invoices +from .views import * # noqa +from .views_api import * # noqa + + +def market_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) diff --git a/lnbits/extensions/market/config.json b/lnbits/extensions/market/config.json new file mode 100644 index 00000000..8a294867 --- /dev/null +++ b/lnbits/extensions/market/config.json @@ -0,0 +1,6 @@ +{ + "name": "Marketplace", + "short_description": "Webshop/market on LNbits", + "tile": "/market/static/images/bitcoin-shop.png", + "contributors": ["benarc", "talvasconcelos"] +} diff --git a/lnbits/extensions/market/crud.py b/lnbits/extensions/market/crud.py new file mode 100644 index 00000000..1d9c28be --- /dev/null +++ b/lnbits/extensions/market/crud.py @@ -0,0 +1,492 @@ +from base64 import urlsafe_b64encode +from typing import List, Optional, Union +from uuid import uuid4 + +# from lnbits.db import open_ext_db +from lnbits.db import SQLITE +from lnbits.helpers import urlsafe_short_hash +from lnbits.settings import WALLET + +from . import db +from .models import ( + ChatMessage, + CreateChatMessage, + CreateMarket, + CreateMarketStalls, + Market, + MarketSettings, + OrderDetail, + Orders, + Products, + Stalls, + Zones, + createOrder, + createOrderDetails, + createProduct, + createStalls, + createZones, +) + +###Products + + +async def create_market_product(data: createProduct) -> Products: + product_id = urlsafe_short_hash() + await db.execute( + f""" + INSERT INTO market.products (id, stall, product, categories, description, image, price, quantity) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + product_id, + data.stall, + data.product, + data.categories, + data.description, + data.image, + data.price, + data.quantity, + ), + ) + product = await get_market_product(product_id) + assert product, "Newly created product couldn't be retrieved" + return product + + +async def update_market_product(product_id: str, **kwargs) -> Optional[Products]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + + await db.execute( + f"UPDATE market.products SET {q} WHERE id = ?", + (*kwargs.values(), product_id), + ) + row = await db.fetchone("SELECT * FROM market.products WHERE id = ?", (product_id,)) + + return Products(**row) if row else None + + +async def get_market_product(product_id: str) -> Optional[Products]: + row = await db.fetchone("SELECT * FROM market.products WHERE id = ?", (product_id,)) + return Products(**row) if row else None + + +async def get_market_products(stall_ids: Union[str, List[str]]) -> List[Products]: + if isinstance(stall_ids, str): + stall_ids = [stall_ids] + + # with open_ext_db("market") as db: + q = ",".join(["?"] * len(stall_ids)) + rows = await db.fetchall( + f""" + SELECT * FROM market.products WHERE stall IN ({q}) + """, + (*stall_ids,), + ) + return [Products(**row) for row in rows] + + +async def delete_market_product(product_id: str) -> None: + await db.execute("DELETE FROM market.products WHERE id = ?", (product_id,)) + + +###zones + + +async def create_market_zone(user, data: createZones) -> Zones: + zone_id = urlsafe_short_hash() + await db.execute( + f""" + INSERT INTO market.zones ( + id, + "user", + cost, + countries + + ) + VALUES (?, ?, ?, ?) + """, + (zone_id, user, data.cost, data.countries.lower()), + ) + + zone = await get_market_zone(zone_id) + assert zone, "Newly created zone couldn't be retrieved" + return zone + + +async def update_market_zone(zone_id: str, **kwargs) -> Optional[Zones]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE market.zones SET {q} WHERE id = ?", + (*kwargs.values(), zone_id), + ) + row = await db.fetchone("SELECT * FROM market.zones WHERE id = ?", (zone_id,)) + return Zones(**row) if row else None + + +async def get_market_zone(zone_id: str) -> Optional[Zones]: + row = await db.fetchone("SELECT * FROM market.zones WHERE id = ?", (zone_id,)) + return Zones(**row) if row else None + + +async def get_market_zones(user: str) -> List[Zones]: + rows = await db.fetchall('SELECT * FROM market.zones WHERE "user" = ?', (user,)) + return [Zones(**row) for row in rows] + + +async def delete_market_zone(zone_id: str) -> None: + await db.execute("DELETE FROM market.zones WHERE id = ?", (zone_id,)) + + +###Stalls + + +async def create_market_stall(data: createStalls) -> Stalls: + stall_id = urlsafe_short_hash() + await db.execute( + f""" + INSERT INTO market.stalls ( + id, + wallet, + name, + currency, + publickey, + relays, + shippingzones + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + stall_id, + data.wallet, + data.name, + data.currency, + data.publickey, + data.relays, + data.shippingzones, + ), + ) + + stall = await get_market_stall(stall_id) + assert stall, "Newly created stall couldn't be retrieved" + return stall + + +async def update_market_stall(stall_id: str, **kwargs) -> Optional[Stalls]: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE market.stalls SET {q} WHERE id = ?", + (*kwargs.values(), stall_id), + ) + row = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,)) + return Stalls(**row) if row else None + + +async def get_market_stall(stall_id: str) -> Optional[Stalls]: + row = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,)) + return Stalls(**row) if row else None + + +async def get_market_stalls(wallet_ids: Union[str, List[str]]) -> List[Stalls]: + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM market.stalls WHERE wallet IN ({q})", (*wallet_ids,) + ) + return [Stalls(**row) for row in rows] + + +async def get_market_stalls_by_ids(stall_ids: Union[str, List[str]]) -> List[Stalls]: + q = ",".join(["?"] * len(stall_ids)) + rows = await db.fetchall( + f"SELECT * FROM market.stalls WHERE id IN ({q})", (*stall_ids,) + ) + return [Stalls(**row) for row in rows] + + +async def delete_market_stall(stall_id: str) -> None: + await db.execute("DELETE FROM market.stalls WHERE id = ?", (stall_id,)) + + +###Orders + + +async def create_market_order(data: createOrder, invoiceid: str): + returning = "" if db.type == SQLITE else "RETURNING ID" + method = db.execute if db.type == SQLITE else db.fetchone + + result = await (method)( + f""" + INSERT INTO market.orders (wallet, shippingzone, address, email, total, invoiceid, paid, shipped) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + {returning} + """, + ( + data.wallet, + data.shippingzone, + data.address, + data.email, + data.total, + invoiceid, + False, + False, + ), + ) + if db.type == SQLITE: + return result._result_proxy.lastrowid + else: + return result[0] + + +async def create_market_order_details(order_id: str, data: List[createOrderDetails]): + for item in data: + item_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO market.order_details (id, order_id, product_id, quantity) + VALUES (?, ?, ?, ?) + """, + ( + item_id, + order_id, + item.product_id, + item.quantity, + ), + ) + order_details = await get_market_order_details(order_id) + return order_details + + +async def get_market_order_details(order_id: str) -> List[OrderDetail]: + rows = await db.fetchall( + f"SELECT * FROM market.order_details WHERE order_id = ?", (order_id,) + ) + + return [OrderDetail(**row) for row in rows] + + +async def get_market_order(order_id: str) -> Optional[Orders]: + row = await db.fetchone("SELECT * FROM market.orders WHERE id = ?", (order_id,)) + return Orders(**row) if row else None + + +async def get_market_order_invoiceid(invoice_id: str) -> Optional[Orders]: + row = await db.fetchone( + "SELECT * FROM market.orders WHERE invoiceid = ?", (invoice_id,) + ) + return Orders(**row) if row else None + + +async def set_market_order_paid(payment_hash: str): + await db.execute( + """ + UPDATE market.orders + SET paid = true + WHERE invoiceid = ? + """, + (payment_hash,), + ) + + +async def set_market_order_pubkey(payment_hash: str, pubkey: str): + await db.execute( + """ + UPDATE market.orders + SET pubkey = ? + WHERE invoiceid = ? + """, + ( + pubkey, + payment_hash, + ), + ) + + +async def update_market_product_stock(products): + + q = "\n".join( + [f"""WHEN id='{p.product_id}' THEN quantity - {p.quantity}""" for p in products] + ) + v = ",".join(["?"] * len(products)) + + await db.execute( + f""" + UPDATE market.products + SET quantity=(CASE + {q} + END) + WHERE id IN ({v}); + """, + (*[p.product_id for p in products],), + ) + + +async def get_market_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM market.orders WHERE wallet IN ({q})", (*wallet_ids,) + ) + # + return [Orders(**row) for row in rows] + + +async def delete_market_order(order_id: str) -> None: + await db.execute("DELETE FROM market.orders WHERE id = ?", (order_id,)) + + +### Market/Marketplace + + +async def get_market_markets(user: str) -> List[Market]: + rows = await db.fetchall("SELECT * FROM market.markets WHERE usr = ?", (user,)) + return [Market(**row) for row in rows] + + +async def get_market_market(market_id: str) -> Optional[Market]: + row = await db.fetchone("SELECT * FROM market.markets WHERE id = ?", (market_id,)) + return Market(**row) if row else None + + +async def get_market_market_stalls(market_id: str): + rows = await db.fetchall( + "SELECT * FROM market.market_stalls WHERE marketid = ?", (market_id,) + ) + + ids = [row["stallid"] for row in rows] + + return await get_market_stalls_by_ids(ids) + + +async def create_market_market(data: CreateMarket): + market_id = urlsafe_short_hash() + + await db.execute( + """ + INSERT INTO market.markets (id, usr, name) + VALUES (?, ?, ?) + """, + ( + market_id, + data.usr, + data.name, + ), + ) + market = await get_market_market(market_id) + assert market, "Newly created market couldn't be retrieved" + return market + + +async def create_market_market_stalls(market_id: str, data: List[str]): + for stallid in data: + id = urlsafe_short_hash() + + await db.execute( + """ + INSERT INTO market.market_stalls (id, marketid, stallid) + VALUES (?, ?, ?) + """, + ( + id, + market_id, + stallid, + ), + ) + market_stalls = await get_market_market_stalls(market_id) + return market_stalls + + +async def update_market_market(market_id: str, name: str): + await db.execute( + "UPDATE market.markets SET name = ? WHERE id = ?", + (name, market_id), + ) + await db.execute( + "DELETE FROM market.market_stalls WHERE marketid = ?", + (market_id,), + ) + + market = await get_market_market(market_id) + return market + + +### CHAT / MESSAGES + + +async def create_chat_message(data: CreateChatMessage): + await db.execute( + """ + INSERT INTO market.messages (msg, pubkey, id_conversation) + VALUES (?, ?, ?) + """, + ( + data.msg, + data.pubkey, + data.room_name, + ), + ) + + +async def get_market_latest_chat_messages(room_name: str): + rows = await db.fetchall( + "SELECT * FROM market.messages WHERE id_conversation = ? ORDER BY timestamp DESC LIMIT 20", + (room_name,), + ) + + return [ChatMessage(**row) for row in rows] + + +async def get_market_chat_messages(room_name: str): + rows = await db.fetchall( + "SELECT * FROM market.messages WHERE id_conversation = ? ORDER BY timestamp DESC", + (room_name,), + ) + + return [ChatMessage(**row) for row in rows] + + +async def get_market_chat_by_merchant(ids: List[str]) -> List[ChatMessage]: + + q = ",".join(["?"] * len(ids)) + rows = await db.fetchall( + f"SELECT * FROM market.messages WHERE id_conversation IN ({q})", + (*ids,), + ) + return [ChatMessage(**row) for row in rows] + + +async def get_market_settings(user) -> Optional[MarketSettings]: + row = await db.fetchone( + """SELECT * FROM market.settings WHERE "user" = ?""", (user,) + ) + + return MarketSettings(**row) if row else None + + +async def create_market_settings(user: str, data): + await db.execute( + """ + INSERT INTO market.settings ("user", currency, fiat_base_multiplier) + VALUES (?, ?, ?) + """, + ( + user, + data.currency, + data.fiat_base_multiplier, + ), + ) + + +async def set_market_settings(user: str, data): + await db.execute( + """ + UPDATE market.settings + SET currency = ?, fiat_base_multiplier = ? + WHERE "user" = ?; + """, + ( + data.currency, + data.fiat_base_multiplier, + user, + ), + ) diff --git a/lnbits/extensions/market/migrations.py b/lnbits/extensions/market/migrations.py new file mode 100644 index 00000000..72b584f9 --- /dev/null +++ b/lnbits/extensions/market/migrations.py @@ -0,0 +1,156 @@ +async def m001_initial(db): + """ + Initial Market settings table. + """ + await db.execute( + """ + CREATE TABLE market.settings ( + "user" TEXT PRIMARY KEY, + currency TEXT DEFAULT 'sat', + fiat_base_multiplier INTEGER DEFAULT 1 + ); + """ + ) + + """ + Initial stalls table. + """ + await db.execute( + """ + CREATE TABLE market.stalls ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL, + currency TEXT, + publickey TEXT, + relays TEXT, + shippingzones TEXT NOT NULL, + rating INTEGER DEFAULT 0 + ); + """ + ) + + """ + Initial products table. + """ + await db.execute( + f""" + CREATE TABLE market.products ( + id TEXT PRIMARY KEY, + stall TEXT NOT NULL REFERENCES {db.references_schema}stalls (id) ON DELETE CASCADE, + product TEXT NOT NULL, + categories TEXT, + description TEXT, + image TEXT, + price INTEGER NOT NULL, + quantity INTEGER NOT NULL, + rating INTEGER DEFAULT 0 + ); + """ + ) + + """ + Initial zones table. + """ + await db.execute( + """ + CREATE TABLE market.zones ( + id TEXT PRIMARY KEY, + "user" TEXT NOT NULL, + cost TEXT NOT NULL, + countries TEXT NOT NULL + ); + """ + ) + + """ + Initial orders table. + """ + await db.execute( + f""" + CREATE TABLE market.orders ( + id {db.serial_primary_key}, + wallet TEXT NOT NULL, + username TEXT, + pubkey TEXT, + shippingzone TEXT NOT NULL, + address TEXT NOT NULL, + email TEXT NOT NULL, + total INTEGER NOT NULL, + invoiceid TEXT NOT NULL, + paid BOOLEAN NOT NULL, + shipped BOOLEAN NOT NULL, + time TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + """ + Initial order details table. + """ + await db.execute( + f""" + CREATE TABLE market.order_details ( + id TEXT PRIMARY KEY, + order_id INTEGER NOT NULL REFERENCES {db.references_schema}orders (id) ON DELETE CASCADE, + product_id TEXT NOT NULL REFERENCES {db.references_schema}products (id) ON DELETE CASCADE, + quantity INTEGER NOT NULL + ); + """ + ) + + """ + Initial market table. + """ + await db.execute( + """ + CREATE TABLE market.markets ( + id TEXT PRIMARY KEY, + usr TEXT NOT NULL, + name TEXT + ); + """ + ) + + """ + Initial market stalls table. + """ + await db.execute( + f""" + CREATE TABLE market.market_stalls ( + id TEXT PRIMARY KEY, + marketid TEXT NOT NULL REFERENCES {db.references_schema}markets (id) ON DELETE CASCADE, + stallid TEXT NOT NULL REFERENCES {db.references_schema}stalls (id) ON DELETE CASCADE + ); + """ + ) + + """ + Initial chat messages table. + """ + await db.execute( + f""" + CREATE TABLE market.messages ( + id {db.serial_primary_key}, + msg TEXT NOT NULL, + pubkey TEXT NOT NULL, + id_conversation TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT """ + + db.timestamp_now + + """ + ); + """ + ) + + if db.type != "SQLITE": + """ + Create indexes for message fetching + """ + await db.execute( + "CREATE INDEX idx_messages_timestamp ON market.messages (timestamp DESC)" + ) + await db.execute( + "CREATE INDEX idx_messages_conversations ON market.messages (id_conversation)" + ) diff --git a/lnbits/extensions/market/models.py b/lnbits/extensions/market/models.py new file mode 100644 index 00000000..ea7f6f20 --- /dev/null +++ b/lnbits/extensions/market/models.py @@ -0,0 +1,135 @@ +from typing import List, Optional + +from fastapi.param_functions import Query +from pydantic import BaseModel + + +class MarketSettings(BaseModel): + user: str + currency: str + fiat_base_multiplier: int + + +class SetSettings(BaseModel): + currency: str + fiat_base_multiplier: int = Query(100, ge=1) + + +class Stalls(BaseModel): + id: str + wallet: str + name: str + currency: str + publickey: Optional[str] + relays: Optional[str] + shippingzones: str + + +class createStalls(BaseModel): + wallet: str = Query(...) + name: str = Query(...) + currency: str = Query("sat") + publickey: str = Query(None) + relays: str = Query(None) + shippingzones: str = Query(...) + + +class createProduct(BaseModel): + stall: str = Query(...) + product: str = Query(...) + categories: str = Query(None) + description: str = Query(None) + image: str = Query(None) + price: float = Query(0, ge=0) + quantity: int = Query(0, ge=0) + + +class Products(BaseModel): + id: str + stall: str + product: str + categories: Optional[str] + description: Optional[str] + image: Optional[str] + price: float + quantity: int + + +class createZones(BaseModel): + cost: float = Query(0, ge=0) + countries: str = Query(...) + + +class Zones(BaseModel): + id: str + user: str + cost: float + countries: str + + +class OrderDetail(BaseModel): + id: str + order_id: str + product_id: str + quantity: int + + +class createOrderDetails(BaseModel): + product_id: str = Query(...) + quantity: int = Query(..., ge=1) + + +class createOrder(BaseModel): + wallet: str = Query(...) + username: str = Query(None) + pubkey: str = Query(None) + shippingzone: str = Query(...) + address: str = Query(...) + email: str = Query(...) + total: int = Query(...) + products: List[createOrderDetails] + + +class Orders(BaseModel): + id: str + wallet: str + username: Optional[str] + pubkey: Optional[str] + shippingzone: str + address: str + email: str + total: int + invoiceid: str + paid: bool + shipped: bool + time: int + + +class CreateMarket(BaseModel): + usr: str = Query(...) + name: str = Query(None) + stalls: List[str] = Query(...) + + +class Market(BaseModel): + id: str + usr: str + name: Optional[str] + + +class CreateMarketStalls(BaseModel): + stallid: str + + +class ChatMessage(BaseModel): + id: str + msg: str + pubkey: str + id_conversation: str + timestamp: int + + +class CreateChatMessage(BaseModel): + msg: str = Query(..., min_length=1) + pubkey: str = Query(...) + room_name: str = Query(...) diff --git a/lnbits/extensions/market/notifier.py b/lnbits/extensions/market/notifier.py new file mode 100644 index 00000000..e2bf7c91 --- /dev/null +++ b/lnbits/extensions/market/notifier.py @@ -0,0 +1,91 @@ +## adapted from https://github.com/Sentymental/chat-fastapi-websocket +""" +Create a class Notifier that will handle messages +and delivery to the specific person +""" + +import json +from collections import defaultdict + +from fastapi import WebSocket +from loguru import logger + +from lnbits.extensions.market.crud import create_chat_message +from lnbits.extensions.market.models import CreateChatMessage + + +class Notifier: + """ + Manages chatrooms, sessions and members. + + Methods: + - get_notification_generator(self): async generator with notification messages + - get_members(self, room_name: str): get members in room + - push(message: str, room_name: str): push message + - connect(websocket: WebSocket, room_name: str): connect to room + - remove(websocket: WebSocket, room_name: str): remove + - _notify(message: str, room_name: str): notifier + """ + + def __init__(self): + # Create sessions as a dict: + self.sessions: dict = defaultdict(dict) + + # Create notification generator: + self.generator = self.get_notification_generator() + + async def get_notification_generator(self): + """Notification Generator""" + + while True: + message = yield + msg = message["message"] + room_name = message["room_name"] + await self._notify(msg, room_name) + + def get_members(self, room_name: str): + """Get all members in a room""" + + try: + logger.info(f"Looking for members in room: {room_name}") + return self.sessions[room_name] + + except Exception: + logger.exception(f"There is no member in room: {room_name}") + return None + + async def push(self, message: str, room_name: str = None): + """Push a message""" + + message_body = {"message": message, "room_name": room_name} + await self.generator.asend(message_body) + + async def connect(self, websocket: WebSocket, room_name: str): + """Connect to room""" + + await websocket.accept() + if self.sessions[room_name] == {} or len(self.sessions[room_name]) == 0: + self.sessions[room_name] = [] + + self.sessions[room_name].append(websocket) + print(f"Connections ...: {self.sessions[room_name]}") + + def remove(self, websocket: WebSocket, room_name: str): + """Remove websocket from room""" + + self.sessions[room_name].remove(websocket) + print(f"Connection removed...\nOpen connections...: {self.sessions[room_name]}") + + async def _notify(self, message: str, room_name: str): + """Notifier""" + d = json.loads(message) + d["room_name"] = room_name + db_msg = CreateChatMessage.parse_obj(d) + await create_chat_message(data=db_msg) + + remaining_sessions = [] + while len(self.sessions[room_name]) > 0: + websocket = self.sessions[room_name].pop() + await websocket.send_text(message) + remaining_sessions.append(websocket) + self.sessions[room_name] = remaining_sessions diff --git a/lnbits/extensions/market/static/images/bitcoin-shop.png b/lnbits/extensions/market/static/images/bitcoin-shop.png new file mode 100644 index 00000000..debffbb2 Binary files /dev/null and b/lnbits/extensions/market/static/images/bitcoin-shop.png differ diff --git a/lnbits/extensions/market/static/images/placeholder.png b/lnbits/extensions/market/static/images/placeholder.png new file mode 100644 index 00000000..c7d3a947 Binary files /dev/null and b/lnbits/extensions/market/static/images/placeholder.png differ diff --git a/lnbits/extensions/market/tasks.py b/lnbits/extensions/market/tasks.py new file mode 100644 index 00000000..b102e0f1 --- /dev/null +++ b/lnbits/extensions/market/tasks.py @@ -0,0 +1,39 @@ +import asyncio + +from loguru import logger + +from lnbits.core.models import Payment +from lnbits.tasks import register_invoice_listener + +from .crud import ( + get_market_order_details, + get_market_order_invoiceid, + set_market_order_paid, + update_market_product_stock, +) + + +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 on_invoice_paid(payment: Payment) -> None: + if payment.extra.get("tag") != "market": + return + + order = await get_market_order_invoiceid(payment.payment_hash) + if not order: + logger.error("this should never happen", payment) + return + + # set order as paid + await set_market_order_paid(payment.payment_hash) + + # deduct items sold from stock + details = await get_market_order_details(order.id) + await update_market_product_stock(details) diff --git a/lnbits/extensions/market/templates/market/_api_docs.html b/lnbits/extensions/market/templates/market/_api_docs.html new file mode 100644 index 00000000..f0d97dbf --- /dev/null +++ b/lnbits/extensions/market/templates/market/_api_docs.html @@ -0,0 +1,128 @@ + + + +
+ LNbits Market (Nostr support coming soon) +
+ +
    +
  1. Create Shipping Zones you're willing to ship to
  2. +
  3. Create a Stall to list yiur products on
  4. +
  5. Create products to put on the Stall
  6. +
  7. Take orders
  8. +
  9. Includes chat support!
  10. +
+ The first LNbits market idea 'Diagon Alley' helped create Nostr, and soon + this market extension will have the option to work on Nostr 'Diagon Alley' + mode, by the merchant, market, and buyer all having keys, and data being + routed through Nostr relays. +
+ + Created by, + Tal Vasconcelos, + Ben Arc + +
+
+
+ + + + + + GET + /market/api/v1/stall/products/<relay_id> +
Body (application/json)
+
+ Returns 201 CREATED (application/json) +
+ Product JSON list +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/stall/products/<relay_id> +
+
+
+ + + + POST + /market/api/v1/stall/order/<relay_id> +
Body (application/json)
+ {"id": <string>, "address": <string>, "shippingzone": + <integer>, "email": <string>, "quantity": + <integer>} +
+ Returns 201 CREATED (application/json) +
+ {"checking_id": <string>,"payment_request": + <string>} +
Curl example
+ curl -X POST {{ request.url_root + }}api/v1/stall/order/<relay_id> -d '{"id": <product_id&>, + "email": <customer_email>, "address": <customer_address>, + "quantity": 2, "shippingzone": 1}' -H "Content-type: application/json" + +
+
+
+ + + + GET + /market/api/v1/stall/checkshipped/<checking_id> +
Headers
+
+ Returns 200 OK (application/json) +
+ {"shipped": <boolean>} +
Curl example
+ curl -X GET {{ request.url_root + }}api/v1/stall/checkshipped/<checking_id> -H "Content-type: + application/json" +
+
+
+
diff --git a/lnbits/extensions/market/templates/market/_chat_box.html b/lnbits/extensions/market/templates/market/_chat_box.html new file mode 100644 index 00000000..05b0c58f --- /dev/null +++ b/lnbits/extensions/market/templates/market/_chat_box.html @@ -0,0 +1,58 @@ + + +
Messages
+
+ + + + + + + +
+
+ +
+ +
+
+ + + + + + + +
+
+
diff --git a/lnbits/extensions/market/templates/market/_dialogs.html b/lnbits/extensions/market/templates/market/_dialogs.html new file mode 100644 index 00000000..d2a8dd0a --- /dev/null +++ b/lnbits/extensions/market/templates/market/_dialogs.html @@ -0,0 +1,393 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ Update Product + + Create Product + + Cancel +
+
+
+
+ + + + + + +
+ Update Shipping Zone + Create Shipping Zone + + Cancel +
+
+
+
+ + + + + + + +
+ Update Marketplace + Launch Marketplace + + Cancel +
+
+
+
+ + + + + + + + + + +
+
+ Generate keys +
+
+ Restore keys +
+
+ + + + + +
+ Update Stall + Create Stall + Cancel +
+
+
+
+ + + +
How to use Market
+ + + Create Shipping Zones you're willing to ship to. You can define + different values for different zones. + + + + + + Create a Stall and provide private and public keys to use for + communication. If you don't have one, LNbits will create a key pair for + you. It will be saved and can be used on other stalls. + + + + + + + Create your products, add a small description and an image. Choose to + what stall, if you have more than one, it belongs to + + + +
+ +
+
+
+
+
diff --git a/lnbits/extensions/market/templates/market/_tables.html b/lnbits/extensions/market/templates/market/_tables.html new file mode 100644 index 00000000..c6fd665b --- /dev/null +++ b/lnbits/extensions/market/templates/market/_tables.html @@ -0,0 +1,440 @@ + + + +
+
+
Orders
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + + +
+
+
+ Products + + + Add a product + +
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + + +
+
+
Market Stalls
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + + +
+
+
Marketplaces
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
+ + + + +
+
+
Shipping Zones
+
+
+ Export to CSV +
+
+ + {% raw %} + + + {% endraw %} + +
+
diff --git a/lnbits/extensions/market/templates/market/index.html b/lnbits/extensions/market/templates/market/index.html new file mode 100644 index 00000000..ffcb612b --- /dev/null +++ b/lnbits/extensions/market/templates/market/index.html @@ -0,0 +1,1419 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+ {% include "market/_dialogs.html" %} +
+ + + + Shipping Zone Create a shipping zone + + Stall + + Create a market stall to list products on + + + Stall + + Create a market stall to list products on + + + Product List a product + + Product List a product + + Create Market + + Makes a simple frontend market for your stalls (not + NOSTR) + + + +
Market
+
Make a market of multiple stalls.
+
+ + + + Coming soon... + Export all Data + + Export all data (markets, products, orders, etc...) + +
+ + {% include "market/_tables.html" %} + + + +
+
+
Keys
+
+
+ Export to CSV +
+
+
+ +
+
+
+ {% raw %} + + + {{ keys[type] }} + +

+ {{ type == 'pubkey' ? 'Public Key' : 'Private Key' }}
Click to copy +

+ {% endraw %} +
+
+
+
+
+
+ +
+ + +
+ LNbits Market Extension (Nostr support coming soon) +
+
+ + + {% include "market/_api_docs.html" %} + +
+ + {% include "market/_chat_box.html" %} +
+
+ +{% endblock %} {% block scripts %} {{ window_vars(user) }} + + + + + + +{% endblock %} diff --git a/lnbits/extensions/market/templates/market/market.html b/lnbits/extensions/market/templates/market/market.html new file mode 100644 index 00000000..e59bb245 --- /dev/null +++ b/lnbits/extensions/market/templates/market/market.html @@ -0,0 +1,175 @@ +{% extends "public.html" %} {% block page %} +
+
+ +
+ Market: {{ market.name }} +
+
+ + + +
+
+
+
+
+
+ + {% raw %} + + + +
+
+ {{ item.product }} +
+
+ + +
+ + +
+
+ {{ item.stallName }} +
+ + {{ item.price }} satsBTC {{ (item.price / 1e8).toFixed(8) }} + + + {{ getAmountFormated(item.price, item.currency) }} + ({{ getValueInSats(item.price, item.currency) }} sats) + + {{item.quantity}} left +
+
+ {{cat}} +
+
+

{{ item.description }}

+
+
+ + + + + Stall: {{ item.stallName }} + + Visit Stall + + + {% endraw %} +
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/market/templates/market/order.html b/lnbits/extensions/market/templates/market/order.html new file mode 100644 index 00000000..5be606f9 --- /dev/null +++ b/lnbits/extensions/market/templates/market/order.html @@ -0,0 +1,564 @@ +{% extends "public.html" %} {% block page %} +
+
+ +
+
+ +
+ +
+
+ + + + + +
+
+
+
+ + + {% raw %} +
{{ stall.name }}
+

+ Public Key: {{ sliceKey(stall.publickey) }} + Click to copy +

+ {% endraw %} +
+ + + + + + + + + {% raw %} + + + {{p.quantity}} x + + + + + + + + {{ p.name }} + + + + {{ getAmountFormated(p.price) }} + {{p.price}} sats + + + {% endraw %} + + + + + +

+ Bellow are the keys needed to contact the merchant. They are + stored in the browser! +

+
+
+
+ {% raw %} + + + {{ user.keys[type] }} + +

+ {{ type == 'publickey' ? 'Public Key' : 'Private Key' }} +

+ {% endraw %} +
+
+
+ +
+ Backup keys + Download your keys + + Restore keys + Restore keys + + Delete data + Delete all data from browser + +
+
+
+ +

Export, or send, this page to another device

+
+ + + Click to copy + +
+
+ Copy URL + Export, or send, this page to another device + +
+
+
+
+
+ + + + + + + +
+ + +
+
+
+
+ + + +
Bookmark this page
+

+ Don't forget to bookmark this page to be able to check on your order! +

+

+ You can backup your keys, and export the page to another device also. +

+
+ Close +
+
+
+
+{% endblock %} {% block scripts %} + + + + +{% endblock %} diff --git a/lnbits/extensions/market/templates/market/product.html b/lnbits/extensions/market/templates/market/product.html new file mode 100644 index 00000000..66f56691 --- /dev/null +++ b/lnbits/extensions/market/templates/market/product.html @@ -0,0 +1,14 @@ +{% extends "public.html" %} {% block page %} +

Product page

+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/market/templates/market/stall.html b/lnbits/extensions/market/templates/market/stall.html new file mode 100644 index 00000000..f9189b30 --- /dev/null +++ b/lnbits/extensions/market/templates/market/stall.html @@ -0,0 +1,531 @@ +{% extends "public.html" %} {% block page %} +
+
+ +
+ Stall: {{ stall.name }} +
+
+ + + +
+ + {% raw %} + + {{ cart.size }} + + {% endraw %} + + + {% raw %} + + + {{p.quantity}} x + + + + + + + + + {{ p.name }} + + + + + {{unit != 'sat' ? getAmountFormated(p.price) : p.price + + 'sats'}} + + + + + {% endraw %} + + +
+ + +
+
+
+
+
+
+
+
+ + {% raw %} + + + + Add to cart + +
+
+ {{ item.product }} +
+
+ + +
+ + +
+ + {{ item.price }} satsBTC {{ (item.price / 1e8).toFixed(8) }} + + + {{ getAmountFormated(item.price) }} + ({{ getValueInSats(item.price) }} sats) + + {{item.quantity}} left +
+
+ {{cat}} +
+
+

{{ item.description }}

+
+
+ + + {% endraw %} +
+
+ + + + + + + + + + + +

Select the shipping zone:

+
+ +
+
+ {% raw %} Total: {{ unit != 'sat' ? getAmountFormated(finalCost) : + finalCost + 'sats' }} + ({{ getValueInSats(finalCost) }} sats) + {% endraw %} +
+
+ Checkout + Cancel +
+
+
+
+ + + + + + +
+ Copy invoice + Close +
+
+
+
+{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/lnbits/extensions/market/views.py b/lnbits/extensions/market/views.py new file mode 100644 index 00000000..23bc5706 --- /dev/null +++ b/lnbits/extensions/market/views.py @@ -0,0 +1,177 @@ +import json +from http import HTTPStatus +from typing import List + +from fastapi import ( + BackgroundTasks, + Depends, + Query, + Request, + WebSocket, + WebSocketDisconnect, +) +from fastapi.templating import Jinja2Templates +from loguru import logger +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists # type: ignore +from lnbits.extensions.market import market_ext, market_renderer +from lnbits.extensions.market.models import CreateChatMessage, SetSettings +from lnbits.extensions.market.notifier import Notifier + +from .crud import ( + create_chat_message, + create_market_settings, + get_market_market, + get_market_market_stalls, + get_market_order_details, + get_market_order_invoiceid, + get_market_products, + get_market_settings, + get_market_stall, + get_market_zone, + get_market_zones, + update_market_product_stock, +) + +templates = Jinja2Templates(directory="templates") + + +@market_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + settings = await get_market_settings(user=user.id) + + if not settings: + await create_market_settings( + user=user.id, data=SetSettings(currency="sat", fiat_base_multiplier=1) + ) + settings = await get_market_settings(user.id) + assert settings + return market_renderer().TemplateResponse( + "market/index.html", + {"request": request, "user": user.dict(), "currency": settings.currency}, + ) + + +@market_ext.get("/stalls/{stall_id}", response_class=HTMLResponse) +async def stall(request: Request, stall_id): + stall = await get_market_stall(stall_id) + products = await get_market_products(stall_id) + + if not stall: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Stall does not exist." + ) + + zones = [] + for id in stall.shippingzones.split(","): + zone = await get_market_zone(id) + assert zone + z = zone.dict() + zones.append({"label": z["countries"], "cost": z["cost"], "value": z["id"]}) + + _stall = stall.dict() + + _stall["zones"] = zones + + return market_renderer().TemplateResponse( + "market/stall.html", + { + "request": request, + "stall": _stall, + "products": [product.dict() for product in products], + }, + ) + + +@market_ext.get("/market/{market_id}", response_class=HTMLResponse) +async def market(request: Request, market_id): + market = await get_market_market(market_id) + + if not market: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Marketplace doesn't exist." + ) + + stalls = await get_market_market_stalls(market_id) + stalls_ids = [stall.id for stall in stalls] + products = [product.dict() for product in await get_market_products(stalls_ids)] + + return market_renderer().TemplateResponse( + "market/market.html", + { + "request": request, + "market": market, + "stalls": [stall.dict() for stall in stalls], + "products": products, + }, + ) + + +@market_ext.get("/order", response_class=HTMLResponse) +async def order_chat( + request: Request, + merch: str = Query(...), + invoice_id: str = Query(...), + keys: str = Query(None), +): + stall = await get_market_stall(merch) + assert stall + order = await get_market_order_invoiceid(invoice_id) + assert order + _order = await get_market_order_details(order.id) + products = await get_market_products(stall.id) + assert products + + return market_renderer().TemplateResponse( + "market/order.html", + { + "request": request, + "stall": { + "id": stall.id, + "name": stall.name, + "publickey": stall.publickey, + "wallet": stall.wallet, + "currency": stall.currency, + }, + "publickey": keys.split(",")[0] if keys else None, + "privatekey": keys.split(",")[1] if keys else None, + "order_id": order.invoiceid, + "order": [details.dict() for details in _order], + "products": [product.dict() for product in products], + }, + ) + + +##################WEBSOCKET ROUTES######################## + +# Initialize Notifier: +notifier = Notifier() + + +@market_ext.websocket("/ws/{room_name}") +async def websocket_endpoint( + websocket: WebSocket, room_name: str, background_tasks: BackgroundTasks +): + await notifier.connect(websocket, room_name) + try: + while True: + data = await websocket.receive_text() + d = json.loads(data) + d["room_name"] = room_name + + room_members = ( + notifier.get_members(room_name) + if notifier.get_members(room_name) is not None + else [] + ) + + if websocket not in room_members: + print("Sender not in room member: Reconnecting...") + await notifier.connect(websocket, room_name) + await notifier._notify(data, room_name) + + except WebSocketDisconnect: + notifier.remove(websocket, room_name) diff --git a/lnbits/extensions/market/views_api.py b/lnbits/extensions/market/views_api.py new file mode 100644 index 00000000..045bc0fc --- /dev/null +++ b/lnbits/extensions/market/views_api.py @@ -0,0 +1,518 @@ +from base64 import urlsafe_b64encode +from http import HTTPStatus +from typing import List, Union +from uuid import uuid4 + +from fastapi import Body, Depends, Query, Request +from loguru import logger +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.core.services import create_invoice +from lnbits.core.views.api import api_payment +from lnbits.decorators import ( + WalletTypeInfo, + get_key_type, + require_admin_key, + require_invoice_key, +) +from lnbits.helpers import urlsafe_short_hash +from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis + +from . import db, market_ext +from .crud import ( + create_market_market, + create_market_market_stalls, + create_market_order, + create_market_order_details, + create_market_product, + create_market_settings, + create_market_stall, + create_market_zone, + delete_market_order, + delete_market_product, + delete_market_stall, + delete_market_zone, + get_market_chat_by_merchant, + get_market_chat_messages, + get_market_latest_chat_messages, + get_market_market, + get_market_market_stalls, + get_market_markets, + get_market_order, + get_market_order_details, + get_market_order_invoiceid, + get_market_orders, + get_market_product, + get_market_products, + get_market_settings, + get_market_stall, + get_market_stalls, + get_market_stalls_by_ids, + get_market_zone, + get_market_zones, + set_market_order_pubkey, + set_market_settings, + update_market_market, + update_market_product, + update_market_stall, + update_market_zone, +) +from .models import ( + CreateMarket, + CreateMarketStalls, + Orders, + Products, + SetSettings, + Stalls, + Zones, + createOrder, + createProduct, + createStalls, + createZones, +) + +# from lnbits.db import open_ext_db + + +### Products +@market_ext.get("/api/v1/products") +async def api_market_products( + wallet: WalletTypeInfo = Depends(require_invoice_key), + all_stalls: bool = Query(False), +): + wallet_ids = [wallet.wallet.id] + + if all_stalls: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + stalls = [stall.id for stall in await get_market_stalls(wallet_ids)] + + if not stalls: + return + + return [product.dict() for product in await get_market_products(stalls)] + + +@market_ext.post("/api/v1/products") +@market_ext.put("/api/v1/products/{product_id}") +async def api_market_product_create( + data: createProduct, + product_id=None, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + # For fiat currencies, + # we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents. + settings = await get_market_settings(user=wallet.wallet.user) + assert settings + + stall = await get_market_stall(stall_id=data.stall) + assert stall + + if stall.currency != "sat": + data.price *= settings.fiat_base_multiplier + + if product_id: + product = await get_market_product(product_id) + if not product: + return {"message": "Product does not exist."} + + # stall = await get_market_stall(stall_id=product.stall) + if stall.wallet != wallet.wallet.id: + return {"message": "Not your product."} + + product = await update_market_product(product_id, **data.dict()) + else: + product = await create_market_product(data=data) + assert product + return product.dict() + + +@market_ext.delete("/api/v1/products/{product_id}") +async def api_market_products_delete( + product_id, wallet: WalletTypeInfo = Depends(require_admin_key) +): + product = await get_market_product(product_id) + + if not product: + return {"message": "Product does not exist."} + + stall = await get_market_stall(product.stall) + assert stall + + if stall.wallet != wallet.wallet.id: + return {"message": "Not your Market."} + + await delete_market_product(product_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +# # # Shippingzones + + +@market_ext.get("/api/v1/zones") +async def api_market_zones(wallet: WalletTypeInfo = Depends(get_key_type)): + + return await get_market_zones(wallet.wallet.user) + + +@market_ext.post("/api/v1/zones") +async def api_market_zone_create( + data: createZones, wallet: WalletTypeInfo = Depends(get_key_type) +): + zone = await create_market_zone(user=wallet.wallet.user, data=data) + return zone.dict() + + +@market_ext.post("/api/v1/zones/{zone_id}") +async def api_market_zone_update( + data: createZones, + zone_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + zone = await get_market_zone(zone_id) + if not zone: + return {"message": "Zone does not exist."} + if zone.user != wallet.wallet.user: + return {"message": "Not your record."} + zone = await update_market_zone(zone_id, **data.dict()) + return zone + + +@market_ext.delete("/api/v1/zones/{zone_id}") +async def api_market_zone_delete( + zone_id, wallet: WalletTypeInfo = Depends(require_admin_key) +): + zone = await get_market_zone(zone_id) + + if not zone: + return {"message": "zone does not exist."} + + if zone.user != wallet.wallet.user: + return {"message": "Not your zone."} + + await delete_market_zone(zone_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +# # # Stalls + + +@market_ext.get("/api/v1/stalls") +async def api_market_stalls( + wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [wallet.wallet.id] + + if all_wallets: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + return [stall.dict() for stall in await get_market_stalls(wallet_ids)] + + +@market_ext.post("/api/v1/stalls") +@market_ext.put("/api/v1/stalls/{stall_id}") +async def api_market_stall_create( + data: createStalls, + stall_id: str = None, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + + if stall_id: + stall = await get_market_stall(stall_id) + if not stall: + return {"message": "Withdraw stall does not exist."} + + if stall.wallet != wallet.wallet.id: + return {"message": "Not your withdraw stall."} + + stall = await update_market_stall(stall_id, **data.dict()) + else: + stall = await create_market_stall(data=data) + assert stall + return stall.dict() + + +@market_ext.delete("/api/v1/stalls/{stall_id}") +async def api_market_stall_delete( + stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): + stall = await get_market_stall(stall_id) + + if not stall: + return {"message": "Stall does not exist."} + + if stall.wallet != wallet.wallet.id: + return {"message": "Not your Stall."} + + await delete_market_stall(stall_id) + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +###Orders + + +@market_ext.get("/api/v1/orders") +async def api_market_orders( + wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False) +): + wallet_ids = [wallet.wallet.id] + if all_wallets: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + + orders = await get_market_orders(wallet_ids) + if not orders: + return + orders_with_details = [] + for order in orders: + _order = order.dict() + _order["details"] = await get_market_order_details(_order["id"]) + orders_with_details.append(_order) + try: + return orders_with_details # [order for order in orders] + # return [order.dict() for order in await get_market_orders(wallet_ids)] + except: + return {"message": "We could not retrieve the orders."} + + +@market_ext.get("/api/v1/orders/{order_id}") +async def api_market_order_by_id(order_id: str): + order = await get_market_order(order_id) + assert order + _order = order.dict() + _order["details"] = await get_market_order_details(order_id) + + return _order + + +@market_ext.post("/api/v1/orders") +async def api_market_order_create(data: createOrder): + ref = urlsafe_short_hash() + + payment_hash, payment_request = await create_invoice( + wallet_id=data.wallet, + amount=data.total, + memo=f"New order on Market", + extra={ + "tag": "market", + "reference": ref, + }, + ) + order_id = await create_market_order(invoiceid=payment_hash, data=data) + logger.debug(f"ORDER ID {order_id}") + logger.debug(f"PRODUCTS {data.products}") + await create_market_order_details(order_id=order_id, data=data.products) + return { + "payment_hash": payment_hash, + "payment_request": payment_request, + "order_reference": ref, + } + + +@market_ext.get("/api/v1/orders/payments/{payment_hash}") +async def api_market_check_payment(payment_hash: str): + order = await get_market_order_invoiceid(payment_hash) + if not order: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Order does not exist." + ) + try: + status = await api_payment(payment_hash) + + except Exception as exc: + logger.error(exc) + return {"paid": False} + return status + + +@market_ext.delete("/api/v1/orders/{order_id}") +async def api_market_order_delete( + order_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): + order = await get_market_order(order_id) + + if not order: + return {"message": "Order does not exist."} + + if order.wallet != wallet.wallet.id: + return {"message": "Not your Order."} + + await delete_market_order(order_id) + + raise HTTPException(status_code=HTTPStatus.NO_CONTENT) + + +# @market_ext.get("/api/v1/orders/paid/{order_id}") +# async def api_market_order_paid( +# order_id, wallet: WalletTypeInfo = Depends(require_admin_key) +# ): +# await db.execute( +# "UPDATE market.orders SET paid = ? WHERE id = ?", +# ( +# True, +# order_id, +# ), +# ) +# return "", HTTPStatus.OK + + +@market_ext.get("/api/v1/order/pubkey/{payment_hash}/{pubkey}") +async def api_market_order_pubkey(payment_hash: str, pubkey: str): + await set_market_order_pubkey(payment_hash, pubkey) + return "", HTTPStatus.OK + + +@market_ext.get("/api/v1/orders/shipped/{order_id}") +async def api_market_order_shipped( + order_id, shipped: bool = Query(...), wallet: WalletTypeInfo = Depends(get_key_type) +): + await db.execute( + "UPDATE market.orders SET shipped = ? WHERE id = ?", + ( + shipped, + order_id, + ), + ) + order = await db.fetchone("SELECT * FROM market.orders WHERE id = ?", (order_id,)) + + return order + + +###List products based on stall id + + +# @market_ext.get("/api/v1/stall/products/{stall_id}") +# async def api_market_stall_products( +# stall_id, wallet: WalletTypeInfo = Depends(get_key_type) +# ): + +# rows = await db.fetchone("SELECT * FROM market.stalls WHERE id = ?", (stall_id,)) +# if not rows: +# return {"message": "Stall does not exist."} + +# products = db.fetchone("SELECT * FROM market.products WHERE wallet = ?", (rows[1],)) +# if not products: +# return {"message": "No products"} + +# return [products.dict() for products in await get_market_products(rows[1])] + + +###Check a product has been shipped + + +# @market_ext.get("/api/v1/stall/checkshipped/{checking_id}") +# async def api_market_stall_checkshipped( +# checking_id, wallet: WalletTypeInfo = Depends(get_key_type) +# ): +# rows = await db.fetchone( +# "SELECT * FROM market.orders WHERE invoiceid = ?", (checking_id,) +# ) +# return {"shipped": rows["shipped"]} + + +## +# MARKETS +## + + +@market_ext.get("/api/v1/markets") +async def api_market_markets(wallet: WalletTypeInfo = Depends(get_key_type)): + # await get_market_market_stalls(market_id="FzpWnMyHQMcRppiGVua4eY") + try: + return [ + market.dict() for market in await get_market_markets(wallet.wallet.user) + ] + except: + return {"message": "We could not retrieve the markets."} + + +@market_ext.get("/api/v1/markets/{market_id}/stalls") +async def api_market_market_stalls(market_id: str): + stall_ids = await get_market_market_stalls(market_id) + return stall_ids + + +@market_ext.post("/api/v1/markets") +@market_ext.put("/api/v1/markets/{market_id}") +async def api_market_market_create( + data: CreateMarket, + market_id: str = None, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + if market_id: + market = await get_market_market(market_id) + if not market: + return {"message": "Market does not exist."} + + if market.usr != wallet.wallet.user: + return {"message": "Not your market."} + + market = await update_market_market(market_id, data.name) + else: + market = await create_market_market(data=data) + + assert market + await create_market_market_stalls(market_id=market.id, data=data.stalls) + + return market.dict() + + +## MESSAGES/CHAT + + +@market_ext.get("/api/v1/chat/messages/merchant") +async def api_get_merchant_messages( + orders: str = Query(...), wallet: WalletTypeInfo = Depends(require_admin_key) +): + return [msg.dict() for msg in await get_market_chat_by_merchant(orders.split(","))] + + +@market_ext.get("/api/v1/chat/messages/{room_name}") +async def api_get_latest_chat_msg(room_name: str, all_messages: bool = Query(False)): + if all_messages: + messages = await get_market_chat_messages(room_name) + else: + messages = await get_market_latest_chat_messages(room_name) + + return messages + + +@market_ext.get("/api/v1/currencies") +async def api_list_currencies_available(): + return list(currencies.keys()) + + +@market_ext.get("/api/v1/settings") +async def api_get_settings(wallet: WalletTypeInfo = Depends(require_admin_key)): + user = wallet.wallet.user + + settings = await get_market_settings(user) + + return settings + + +@market_ext.post("/api/v1/settings") +@market_ext.put("/api/v1/settings/{usr}") +async def api_set_settings( + data: SetSettings, + usr: str = None, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + if usr: + if usr != wallet.wallet.user: + return {"message": "Not your Market."} + + settings = await get_market_settings(user=usr) + assert settings + + if settings.user != wallet.wallet.user: + return {"message": "Not your Market."} + + return await set_market_settings(usr, data) + + user = wallet.wallet.user + + return await create_market_settings(user, data) diff --git a/lnbits/extensions/nostrnip5/tasks.py b/lnbits/extensions/nostrnip5/tasks.py index 30e8cec6..f0d0c965 100644 --- a/lnbits/extensions/nostrnip5/tasks.py +++ b/lnbits/extensions/nostrnip5/tasks.py @@ -18,8 +18,6 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if not payment.extra: - return if payment.extra.get("tag") != "nostrnip5": return diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index f6ff0c36..ca4e6bac 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -1,15 +1,9 @@ -import hashlib - -from fastapi.params import Query -from lnurl import ( # type: ignore - LnurlErrorResponse, - LnurlPayActionResponse, - LnurlPayResponse, -) +from fastapi import Query +from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse +from lnurl.models import ClearnetUrl, LightningInvoice, MilliSatoshi 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 @@ -17,8 +11,8 @@ from .crud import get_item, get_shop @offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response") -async def lnurl_response(req: Request, item_id: int = Query(...)): - item = await get_item(item_id) # type: Item +async def lnurl_response(req: Request, item_id: int = Query(...)) -> dict: + item = await get_item(item_id) if not item: return {"status": "ERROR", "reason": "Item not found."} @@ -32,9 +26,11 @@ async def lnurl_response(req: Request, item_id: int = Query(...)): ) * 1000 resp = LnurlPayResponse( - callback=req.url_for("offlineshop.lnurl_callback", item_id=item.id), - min_sendable=price_msat, - max_sendable=price_msat, + callback=ClearnetUrl( + req.url_for("offlineshop.lnurl_callback", item_id=item.id), scheme="https" + ), + minSendable=MilliSatoshi(price_msat), + maxSendable=MilliSatoshi(price_msat), metadata=await item.lnurlpay_metadata(), ) @@ -43,7 +39,7 @@ async def lnurl_response(req: Request, item_id: int = Query(...)): @offlineshop_ext.get("/lnurl/cb/{item_id}", name="offlineshop.lnurl_callback") async def lnurl_callback(request: Request, item_id: int): - item = await get_item(item_id) # type: Item + item = await get_item(item_id) if not item: return {"status": "ERROR", "reason": "Couldn't find item."} @@ -67,6 +63,7 @@ async def lnurl_callback(request: Request, item_id: int): ).dict() shop = await get_shop(item.shop) + assert shop try: payment_hash, payment_request = await create_invoice( @@ -77,14 +74,15 @@ async def lnurl_callback(request: Request, item_id: int): extra={"tag": "offlineshop", "item": item.id}, ) except Exception as exc: - return LnurlErrorResponse(reason=exc.message).dict() + return LnurlErrorResponse(reason=str(exc)).dict() - resp = LnurlPayActionResponse( - pr=payment_request, - success_action=item.success_action(shop, payment_hash, request) - if shop.method - else None, - routes=[], - ) + if shop.method: + success_action = item.success_action(shop, payment_hash, request) + assert success_action + resp = LnurlPayActionResponse( + pr=LightningInvoice(payment_request), + successAction=success_action, + routes=[], + ) - return resp.dict() + return resp.dict() diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index ca5c73a5..d2e3b3d2 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -5,9 +5,9 @@ from collections import OrderedDict from sqlite3 import Row from typing import Dict, List, Optional -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 lnurl import encode as lnurl_encode +from lnurl.models import ClearnetUrl, Max144Str, UrlAction +from lnurl.types import LnurlPayMetadata from pydantic import BaseModel from starlette.requests import Request @@ -119,11 +119,16 @@ class Item(BaseModel): def success_action( self, shop: Shop, payment_hash: str, req: Request - ) -> Optional[LnurlPaySuccessAction]: + ) -> Optional[UrlAction]: if not shop.wordlist: return None return UrlAction( - url=req.url_for("offlineshop.confirmation_code", p=payment_hash), - description="Open to get the confirmation code for your purchase.", + url=ClearnetUrl( + req.url_for("offlineshop.confirmation_code", p=payment_hash), + scheme="https", + ), + description=Max144Str( + "Open to get the confirmation code for your purchase." + ), ) diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py index 34bb7a03..3c2aaf5a 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -3,8 +3,7 @@ from datetime import datetime from http import HTTPStatus from typing import List -from fastapi import HTTPException, Request -from fastapi.params import Depends, Query +from fastapi import Depends, HTTPException, Query, Request from starlette.responses import HTMLResponse from lnbits.core.crud import get_standalone_payment @@ -25,10 +24,10 @@ async def index(request: Request, user: User = Depends(check_user_exists)): @offlineshop_ext.get("/print", response_class=HTMLResponse) -async def print_qr_codes(request: Request, items: List[int] = None): +async def print_qr_codes(request: Request): items = [] for item_id in request.query_params.get("items").split(","): - item = await get_item(item_id) # type: Item + item = await get_item(item_id) if item: items.append( { @@ -53,7 +52,8 @@ async def confirmation_code(p: str = Query(...)): payment_hash = p await api_payment(payment_hash) - payment: Payment = await get_standalone_payment(payment_hash) + + payment = await get_standalone_payment(payment_hash) if not payment: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, @@ -72,8 +72,13 @@ async def confirmation_code(p: str = Query(...)): detail="Too much time has passed." + style, ) - item = await get_item(payment.extra.get("item")) + assert payment.extra + item_id = payment.extra.get("item") + assert item_id + item = await get_item(item_id) + assert item shop = await get_shop(item.shop) + assert shop return ( f""" diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py index 71583b9e..5e9f7e0d 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -1,13 +1,9 @@ from http import HTTPStatus from typing import Optional -from fastapi import Query -from fastapi.params import Depends +from fastapi import Depends, HTTPException, Query, Request, Response from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl -from pydantic.main import BaseModel -from starlette.exceptions import HTTPException -from starlette.requests import Request -from starlette.responses import HTMLResponse # type: ignore +from pydantic import BaseModel from lnbits.decorators import WalletTypeInfo, get_key_type from lnbits.utils.exchange_rates import currencies @@ -34,6 +30,7 @@ async def api_shop_from_wallet( r: Request, wallet: WalletTypeInfo = Depends(get_key_type) ): shop = await get_or_create_shop_by_wallet(wallet.wallet.id) + assert shop items = await get_items(shop.id) try: return { @@ -62,6 +59,7 @@ 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) + assert shop if data.unit != "sat": data.price = data.price * 100 if item_id == None: @@ -71,11 +69,11 @@ async def api_add_or_update_item( data.name, data.description, data.image, - data.price, + int(data.price), data.unit, data.fiat_base_multiplier, ) - return HTMLResponse(status_code=HTTPStatus.CREATED) + return Response(status_code=HTTPStatus.CREATED) else: await update_item( shop.id, @@ -83,7 +81,7 @@ async def api_add_or_update_item( data.name, data.description, data.image, - data.price, + int(data.price), data.unit, data.fiat_base_multiplier, ) @@ -92,6 +90,7 @@ async def api_add_or_update_item( @offlineshop_ext.delete("/api/v1/offlineshop/items/{item_id}") async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)): shop = await get_or_create_shop_by_wallet(wallet.wallet.id) + assert shop await delete_item_from_shop(shop.id, item_id) return "", HTTPStatus.NO_CONTENT @@ -107,7 +106,7 @@ async def api_set_method( ): method = data.method - wordlist = data.wordlist.split("\n") if data.wordlist else None + wordlist = data.wordlist.split("\n") if data.wordlist else [] wordlist = [word.strip() for word in wordlist if word.strip()] shop = await get_or_create_shop_by_wallet(wallet.wallet.id) diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py index 78433838..4fb14695 100644 --- a/lnbits/extensions/satspay/crud.py +++ b/lnbits/extensions/satspay/crud.py @@ -12,14 +12,13 @@ from . import db from .helpers import fetch_onchain_balance from .models import Charges, CreateCharge, SatsPayThemes -###############CHARGES########################## - -async def create_charge(user: str, data: CreateCharge) -> Charges: +async def create_charge(user: str, data: CreateCharge) -> Optional[Charges]: data = CreateCharge(**data.dict()) charge_id = urlsafe_short_hash() if data.onchainwallet: config = await get_config(user) + assert config data.extra = json.dumps( {"mempool_endpoint": config.mempool_endpoint, "network": config.network} ) @@ -92,7 +91,7 @@ async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]: return Charges.from_row(row) if row else None -async def get_charge(charge_id: str) -> Charges: +async def get_charge(charge_id: str) -> Optional[Charges]: row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) return Charges.from_row(row) if row else None @@ -111,6 +110,7 @@ async def delete_charge(charge_id: str) -> None: async def check_address_balance(charge_id: str) -> Optional[Charges]: charge = await get_charge(charge_id) + assert charge if not charge.paid: if charge.onchainaddress: @@ -131,7 +131,7 @@ async def check_address_balance(charge_id: str) -> Optional[Charges]: ################## SETTINGS ################### -async def save_theme(data: SatsPayThemes, css_id: str = None): +async def save_theme(data: SatsPayThemes, css_id: Optional[str]): # insert or update if css_id: await db.execute( @@ -162,7 +162,7 @@ async def save_theme(data: SatsPayThemes, css_id: str = None): return await get_theme(css_id) -async def get_theme(css_id: str) -> SatsPayThemes: +async def get_theme(css_id: str) -> Optional[SatsPayThemes]: row = await db.fetchone("SELECT * FROM satspay.themes WHERE css_id = ?", (css_id,)) return SatsPayThemes.from_row(row) if row else None diff --git a/lnbits/extensions/satspay/helpers.py b/lnbits/extensions/satspay/helpers.py index b21a3ae2..8596d368 100644 --- a/lnbits/extensions/satspay/helpers.py +++ b/lnbits/extensions/satspay/helpers.py @@ -32,6 +32,7 @@ def public_charge(charge: Charges): async def call_webhook(charge: Charges): async with httpx.AsyncClient() as client: try: + assert charge.webhook r = await client.post( charge.webhook, json=public_charge(charge), @@ -54,6 +55,8 @@ async def fetch_onchain_balance(charge: Charges): if charge.config.network == "Testnet" else charge.config.mempool_endpoint ) + assert endpoint + assert charge.onchainaddress async with httpx.AsyncClient() as client: r = await client.get(endpoint + "/api/address/" + charge.onchainaddress) return r.json()["chain_stats"]["funded_txo_sum"] diff --git a/lnbits/extensions/satspay/tasks.py b/lnbits/extensions/satspay/tasks.py index ce54b44a..992e5eb6 100644 --- a/lnbits/extensions/satspay/tasks.py +++ b/lnbits/extensions/satspay/tasks.py @@ -22,10 +22,12 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: + if payment.extra.get("tag") != "charge": # not a charge invoice return + assert payment.memo charge = await get_charge(payment.memo) if not charge: logger.error("this should never happen", payment) @@ -33,6 +35,7 @@ async def on_invoice_paid(payment: Payment) -> None: await payment.set_pending(False) charge = await check_address_balance(charge_id=charge.id) + assert charge if charge.must_call_webhook(): resp = await call_webhook(charge) diff --git a/lnbits/extensions/satspay/views.py b/lnbits/extensions/satspay/views.py index 90f8a6b9..15a4403d 100644 --- a/lnbits/extensions/satspay/views.py +++ b/lnbits/extensions/satspay/views.py @@ -1,10 +1,7 @@ from http import HTTPStatus -from fastapi import Response -from fastapi.param_functions import Depends +from fastapi import Depends, HTTPException, Request, Response 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 diff --git a/lnbits/extensions/satspay/views_api.py b/lnbits/extensions/satspay/views_api.py index 08c731cb..98c338ed 100644 --- a/lnbits/extensions/satspay/views_api.py +++ b/lnbits/extensions/satspay/views_api.py @@ -1,9 +1,8 @@ import json from http import HTTPStatus -from fastapi import Depends, Query +from fastapi import Depends, HTTPException, Query from loguru import logger -from starlette.exceptions import HTTPException from lnbits.decorators import ( WalletTypeInfo, @@ -29,8 +28,6 @@ from .crud import ( from .helpers import call_webhook, public_charge from .models import CreateCharge, SatsPayThemes -#############################CHARGES########################## - @satspay_ext.post("/api/v1/charge") async def api_charge_create( @@ -38,6 +35,7 @@ async def api_charge_create( ): try: charge = await create_charge(user=wallet.wallet.user, data=data) + assert charge return { **charge.dict(), **{"time_elapsed": charge.time_elapsed}, @@ -51,13 +49,15 @@ async def api_charge_create( ) -@satspay_ext.put("/api/v1/charge/{charge_id}") +@satspay_ext.put( + "/api/v1/charge/{charge_id}", dependencies=[Depends(require_admin_key)] +) async def api_charge_update( data: CreateCharge, - wallet: WalletTypeInfo = Depends(require_admin_key), - charge_id=None, + charge_id: str, ): charge = await update_charge(charge_id=charge_id, data=data) + assert charge return charge.dict() @@ -78,10 +78,8 @@ async def api_charges_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): return "" -@satspay_ext.get("/api/v1/charge/{charge_id}") -async def api_charge_retrieve( - charge_id, wallet: WalletTypeInfo = Depends(get_key_type) -): +@satspay_ext.get("/api/v1/charge/{charge_id}", dependencies=[Depends(get_key_type)]) +async def api_charge_retrieve(charge_id: str): charge = await get_charge(charge_id) if not charge: @@ -97,8 +95,8 @@ async def api_charge_retrieve( } -@satspay_ext.delete("/api/v1/charge/{charge_id}") -async def api_charge_delete(charge_id, wallet: WalletTypeInfo = Depends(get_key_type)): +@satspay_ext.delete("/api/v1/charge/{charge_id}", dependencies=[Depends(get_key_type)]) +async def api_charge_delete(charge_id: str): charge = await get_charge(charge_id) if not charge: @@ -155,7 +153,7 @@ async def api_themes_save( theme = await save_theme(css_id=css_id, data=data) else: data.user = wallet.wallet.user - theme = await save_theme(data=data) + theme = await save_theme(data=data, css_id="no_id") return theme @@ -169,8 +167,8 @@ async def api_themes_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): return "" -@satspay_ext.delete("/api/v1/themes/{theme_id}") -async def api_theme_delete(theme_id, wallet: WalletTypeInfo = Depends(get_key_type)): +@satspay_ext.delete("/api/v1/themes/{theme_id}", dependencies=[Depends(get_key_type)]) +async def api_theme_delete(theme_id): theme = await get_theme(theme_id) if not theme: diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py index 096cbef9..26249bb1 100644 --- a/lnbits/extensions/scrub/tasks.py +++ b/lnbits/extensions/scrub/tasks.py @@ -27,7 +27,7 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment): # (avoid loops) - if payment.extra and payment.extra.get("tag") == "scrubed": + if payment.extra.get("tag") == "scrubed": # already scrubbed return diff --git a/lnbits/extensions/streamalerts/crud.py b/lnbits/extensions/streamalerts/crud.py index 1e7239a9..37583117 100644 --- a/lnbits/extensions/streamalerts/crud.py +++ b/lnbits/extensions/streamalerts/crud.py @@ -25,15 +25,20 @@ async def get_charge_details(service_id): These might be different depending for services implemented in the future. """ - details = {"time": 1440} service = await get_service(service_id) + assert service + wallet_id = service.wallet wallet = await get_wallet(wallet_id) + assert wallet + user = wallet.user - details["user"] = user - details["lnbitswallet"] = wallet_id - details["onchainwallet"] = service.onchain - return details + return { + "time": 1440, + "user": user, + "lnbitswallet": wallet_id, + "onchainwallet": service.onchain, + } async def create_donation( @@ -71,7 +76,7 @@ async def create_donation( return donation -async def post_donation(donation_id: str) -> tuple: +async def post_donation(donation_id: str) -> dict: """Post donations to their respective third party APIs If the donation has already been posted, it will not be posted again. @@ -97,7 +102,6 @@ async def post_donation(donation_id: str) -> tuple: } async with httpx.AsyncClient() as client: response = await client.post(url, data=data) - status = [s for s in list(HTTPStatus) if s == response.status_code][0] elif service.servicename == "StreamElements": return {"message": "StreamElements not yet supported!"} else: diff --git a/lnbits/extensions/streamalerts/models.py b/lnbits/extensions/streamalerts/models.py index 4a365cba..ae0ffab5 100644 --- a/lnbits/extensions/streamalerts/models.py +++ b/lnbits/extensions/streamalerts/models.py @@ -1,8 +1,8 @@ from sqlite3 import Row from typing import Optional -from fastapi.params import Query -from pydantic.main import BaseModel +from fastapi import Query +from pydantic import BaseModel class CreateService(BaseModel): diff --git a/lnbits/extensions/streamalerts/views.py b/lnbits/extensions/streamalerts/views.py index 595b841e..ac63e9c5 100644 --- a/lnbits/extensions/streamalerts/views.py +++ b/lnbits/extensions/streamalerts/views.py @@ -1,6 +1,6 @@ from http import HTTPStatus -from fastapi.param_functions import Depends +from fastapi import Depends from fastapi.templating import Jinja2Templates from starlette.exceptions import HTTPException from starlette.requests import Request diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py index 058f5126..0134fe82 100644 --- a/lnbits/extensions/streamalerts/views_api.py +++ b/lnbits/extensions/streamalerts/views_api.py @@ -1,6 +1,6 @@ from http import HTTPStatus -from fastapi.params import Depends, Query +from fastapi import Depends, Query from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import RedirectResponse @@ -84,6 +84,8 @@ async def api_authenticate_service( """ service = await get_service(service_id) + assert service + if service.state != state: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="State doesn't match!" @@ -113,6 +115,7 @@ async def api_create_donation(data: CreateDonation, request: Request): webhook_base = request.url.scheme + "://" + request.headers["Host"] service_id = data.service service = await get_service(service_id) + assert service charge_details = await get_charge_details(service.id) name = data.name if data.name else "Anonymous" @@ -157,7 +160,8 @@ async def api_post_donation(request: Request, data: ValidateDonation): @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 + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] services = [] for wallet_id in wallet_ids: new_services = await get_services(wallet_id) @@ -170,7 +174,8 @@ 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 + user = await get_user(g.wallet.user) + wallet_ids = user.wallet_ids if user else [] donations = [] for wallet_id in wallet_ids: new_donations = await get_donations(wallet_id) diff --git a/lnbits/extensions/subdomains/tasks.py b/lnbits/extensions/subdomains/tasks.py index c5a7f47b..f9e0c8ee 100644 --- a/lnbits/extensions/subdomains/tasks.py +++ b/lnbits/extensions/subdomains/tasks.py @@ -20,7 +20,7 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: - if not payment.extra or payment.extra.get("tag") != "lnsubdomain": + if payment.extra.get("tag") != "lnsubdomain": # not an lnurlp invoice return diff --git a/lnbits/extensions/tpos/tasks.py b/lnbits/extensions/tpos/tasks.py index ad018808..fe9fb7e6 100644 --- a/lnbits/extensions/tpos/tasks.py +++ b/lnbits/extensions/tpos/tasks.py @@ -26,7 +26,6 @@ async def on_invoice_paid(payment: Payment) -> None: if payment.extra.get("tag") != "tpos": return - tpos = await get_tpos(payment.extra.get("tposId")) tipAmount = payment.extra.get("tipAmount") strippedPayment = { @@ -37,14 +36,23 @@ async def on_invoice_paid(payment: Payment) -> None: "bolt11": payment.bolt11, } - await websocketUpdater(payment.extra.get("tposId"), str(strippedPayment)) + tpos_id = payment.extra.get("tposId") + assert tpos_id - if tipAmount is None: + tpos = await get_tpos(tpos_id) + assert tpos + + await websocketUpdater(tpos_id, str(strippedPayment)) + + if not tipAmount: # no tip amount return + wallet_id = tpos.tip_wallet + assert wallet_id + payment_hash, payment_request = await create_invoice( - wallet_id=tpos.tip_wallet, + wallet_id=wallet_id, amount=int(tipAmount), # sats internal=True, memo=f"tpos tip", diff --git a/lnbits/extensions/tpos/views.py b/lnbits/extensions/tpos/views.py index dac129a9..fee5914f 100644 --- a/lnbits/extensions/tpos/views.py +++ b/lnbits/extensions/tpos/views.py @@ -1,7 +1,6 @@ from http import HTTPStatus -from fastapi import Request -from fastapi.params import Depends +from fastapi import Depends, Request from fastapi.templating import Jinja2Templates from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py index 76dcf9c4..ada54b3f 100644 --- a/lnbits/extensions/tpos/views_api.py +++ b/lnbits/extensions/tpos/views_api.py @@ -1,8 +1,7 @@ from http import HTTPStatus import httpx -from fastapi import Query -from fastapi.params import Depends +from fastapi import Depends, Query from lnurl import decode as decode_lnurl from loguru import logger from starlette.exceptions import HTTPException @@ -25,7 +24,8 @@ async def api_tposs( ): wallet_ids = [wallet.wallet.id] if all_wallets: - wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] return [tpos.dict() for tpos in await get_tposs(wallet_ids)] @@ -58,8 +58,9 @@ async def api_tpos_delete( @tpos_ext.post("/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED) async def api_tpos_create_invoice( - amount: int = Query(..., ge=1), tipAmount: int = None, tpos_id: str = None -): + tpos_id: str, amount: int = Query(..., ge=1), tipAmount: int = 0 +) -> dict: + tpos = await get_tpos(tpos_id) if not tpos: @@ -67,7 +68,7 @@ async def api_tpos_create_invoice( status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist." ) - if tipAmount: + if tipAmount > 0: amount += tipAmount try: @@ -89,7 +90,7 @@ async def api_tpos_create_invoice( @tpos_ext.get("/api/v1/tposs/{tpos_id}/invoices") -async def api_tpos_get_latest_invoices(tpos_id: str = None): +async def api_tpos_get_latest_invoices(tpos_id: str): try: payments = [ Payment.from_row(row) @@ -116,7 +117,7 @@ async def api_tpos_get_latest_invoices(tpos_id: str = None): "/api/v1/tposs/{tpos_id}/invoices/{payment_request}/pay", status_code=HTTPStatus.OK ) async def api_tpos_pay_invoice( - lnurl_data: PayLnurlWData, payment_request: str = None, tpos_id: str = None + lnurl_data: PayLnurlWData, payment_request: str, tpos_id: str ): tpos = await get_tpos(tpos_id) diff --git a/lnbits/extensions/withdraw/crud.py b/lnbits/extensions/withdraw/crud.py index 83404c62..83dd0593 100644 --- a/lnbits/extensions/withdraw/crud.py +++ b/lnbits/extensions/withdraw/crud.py @@ -1,6 +1,8 @@ from datetime import datetime from typing import List, Optional, Union +import shortuuid + from lnbits.helpers import urlsafe_short_hash from . import db @@ -8,9 +10,10 @@ from .models import CreateWithdrawData, HashCheck, WithdrawLink async def create_withdraw_link( - data: CreateWithdrawData, wallet_id: str, usescsv: str + data: CreateWithdrawData, wallet_id: str ) -> WithdrawLink: - link_id = urlsafe_short_hash() + link_id = urlsafe_short_hash()[:6] + available_links = ",".join([str(i) for i in range(data.uses)]) await db.execute( """ INSERT INTO withdraw.withdraw_link ( @@ -45,7 +48,7 @@ async def create_withdraw_link( urlsafe_short_hash(), urlsafe_short_hash(), int(datetime.now().timestamp()) + data.wait_time, - usescsv, + available_links, data.webhook_url, data.webhook_headers, data.webhook_body, @@ -94,6 +97,26 @@ async def get_withdraw_links(wallet_ids: Union[str, List[str]]) -> List[Withdraw return [WithdrawLink(**row) for row in rows] +async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None: + unique_links = [ + x.strip() + for x in link.usescsv.split(",") + if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip()) + ] + await update_withdraw_link( + link.id, + usescsv=",".join(unique_links), + ) + + +async def increment_withdraw_link(link: WithdrawLink) -> None: + await update_withdraw_link( + link.id, + used=link.used + 1, + open_time=link.wait_time + int(datetime.now().timestamp()), + ) + + async def update_withdraw_link(link_id: str, **kwargs) -> Optional[WithdrawLink]: if "is_unique" in kwargs: kwargs["is_unique"] = int(kwargs["is_unique"]) @@ -132,7 +155,7 @@ async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: return hashCheck -async def get_hash_check(the_hash: str, lnurl_id: str) -> Optional[HashCheck]: +async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck: rowid = await db.fetchone( "SELECT * FROM withdraw.hash_check WHERE id = ?", (the_hash,) ) @@ -141,10 +164,10 @@ async def get_hash_check(the_hash: str, lnurl_id: str) -> Optional[HashCheck]: ) if not rowlnurl: await create_hash_check(the_hash, lnurl_id) - return {"lnurl": True, "hash": False} + return HashCheck(lnurl=True, hash=False) else: if not rowid: await create_hash_check(the_hash, lnurl_id) - return {"lnurl": True, "hash": False} + return HashCheck(lnurl=True, hash=False) else: - return {"lnurl": True, "hash": True} + return HashCheck(lnurl=True, hash=True) diff --git a/lnbits/extensions/withdraw/lnurl.py b/lnbits/extensions/withdraw/lnurl.py index 86640443..5ef521fa 100644 --- a/lnbits/extensions/withdraw/lnurl.py +++ b/lnbits/extensions/withdraw/lnurl.py @@ -1,28 +1,27 @@ import json -import traceback from datetime import datetime from http import HTTPStatus import httpx -import shortuuid # type: ignore -from fastapi import HTTPException -from fastapi.param_functions import Query +import shortuuid +from fastapi import HTTPException, Query, Request, Response from loguru import logger -from starlette.requests import Request -from starlette.responses import HTMLResponse from lnbits.core.crud import update_payment_extra from lnbits.core.services import pay_invoice from . import withdraw_ext -from .crud import get_withdraw_link_by_hash, update_withdraw_link - -# FOR LNURLs WHICH ARE NOT UNIQUE +from .crud import ( + get_withdraw_link_by_hash, + increment_withdraw_link, + remove_unique_withdraw_link, +) +from .models import WithdrawLink @withdraw_ext.get( "/api/v1/lnurl/{unique_hash}", - response_class=HTMLResponse, + response_class=Response, name="withdraw.api_lnurl_response", ) async def api_lnurl_response(request: Request, unique_hash): @@ -53,9 +52,6 @@ async def api_lnurl_response(request: Request, unique_hash): return json.dumps(withdrawResponse) -# CALLBACK - - @withdraw_ext.get( "/api/v1/lnurl/cb/{unique_hash}", name="withdraw.api_lnurl_callback", @@ -99,105 +95,79 @@ async def api_lnurl_callback( detail=f"wait link open_time {link.open_time - now} seconds.", ) - usescsv = "" - - for x in range(1, link.uses - link.used): - usecv = link.usescsv.split(",") - usescsv += "," + str(usecv[x]) - usecsvback = usescsv - - found = False - if id_unique_hash is not None: - useslist = link.usescsv.split(",") - for ind, x in enumerate(useslist): - tohash = link.id + link.unique_hash + str(x) - if id_unique_hash == shortuuid.uuid(name=tohash): - found = True - useslist.pop(ind) - usescsv = ",".join(useslist) - if not found: + if id_unique_hash: + if check_unique_link(link, id_unique_hash): + await remove_unique_withdraw_link(link, id_unique_hash) + else: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found." ) - else: - usescsv = usescsv[1:] - - changesback = { - "open_time": link.wait_time, - "used": link.used, - "usescsv": usecsvback, - } try: - changes = { - "open_time": link.wait_time + now, - "used": link.used + 1, - "usescsv": usescsv, - } - await update_withdraw_link(link.id, **changes) - - payment_request = pr - payment_hash = await pay_invoice( wallet_id=link.wallet, - payment_request=payment_request, + payment_request=pr, max_sat=link.max_withdrawable, extra={"tag": "withdraw"}, ) - + await increment_withdraw_link(link) if link.webhook_url: - async with httpx.AsyncClient() as client: - try: - kwargs = { - "json": { - "payment_hash": payment_hash, - "payment_request": payment_request, - "lnurlw": link.id, - }, - "timeout": 40, - } - if link.webhook_body: - kwargs["json"]["body"] = json.loads(link.webhook_body) - if link.webhook_headers: - kwargs["headers"] = json.loads(link.webhook_headers) - - r: httpx.Response = await client.post(link.webhook_url, **kwargs) - await update_payment_extra( - payment_hash=payment_hash, - extra={ - "wh_success": r.is_success, - "wh_message": r.reason_phrase, - "wh_response": r.text, - }, - outgoing=True, - ) - except Exception as exc: - # webhook fails shouldn't cause the lnurlw to fail since invoice is already paid - logger.error( - "Caught exception when dispatching webhook url: " + str(exc) - ) - await update_payment_extra( - payment_hash=payment_hash, - extra={"wh_success": False, "wh_message": str(exc)}, - outgoing=True, - ) - + await dispatch_webhook(link, payment_hash, pr) return {"status": "OK"} - except Exception as e: - await update_withdraw_link(link.id, **changesback) - logger.error(traceback.format_exc()) raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {str(e)}" ) +def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool: + return any( + unique_hash == shortuuid.uuid(name=link.id + link.unique_hash + x.strip()) + for x in link.usescsv.split(",") + ) + + +async def dispatch_webhook( + link: WithdrawLink, payment_hash: str, payment_request: str +) -> None: + async with httpx.AsyncClient() as client: + try: + r: httpx.Response = await client.post( + link.webhook_url, + json={ + "payment_hash": payment_hash, + "payment_request": payment_request, + "lnurlw": link.id, + "body": json.loads(link.webhook_body) if link.webhook_body else "", + }, + headers=json.loads(link.webhook_headers) + if link.webhook_headers + else None, + timeout=40, + ) + await update_payment_extra( + payment_hash=payment_hash, + extra={ + "wh_success": r.is_success, + "wh_message": r.reason_phrase, + "wh_response": r.text, + }, + outgoing=True, + ) + except Exception as exc: + # webhook fails shouldn't cause the lnurlw to fail since invoice is already paid + logger.error("Caught exception when dispatching webhook url: " + str(exc)) + await update_payment_extra( + payment_hash=payment_hash, + extra={"wh_success": False, "wh_message": str(exc)}, + outgoing=True, + ) + + # FOR LNURLs WHICH ARE UNIQUE - - @withdraw_ext.get( "/api/v1/lnurl/{unique_hash}/{id_unique_hash}", - response_class=HTMLResponse, + response_class=Response, name="withdraw.api_lnurl_multi_response", ) async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash): @@ -213,14 +183,7 @@ async def api_lnurl_multi_response(request: Request, unique_hash, id_unique_hash status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent." ) - useslist = link.usescsv.split(",") - found = False - for x in useslist: - tohash = link.id + link.unique_hash + str(x) - if id_unique_hash == shortuuid.uuid(name=tohash): - found = True - - if not found: + if not check_unique_link(link, id_unique_hash): raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found." ) diff --git a/lnbits/extensions/withdraw/models.py b/lnbits/extensions/withdraw/models.py index 51c6a1cf..49421a79 100644 --- a/lnbits/extensions/withdraw/models.py +++ b/lnbits/extensions/withdraw/models.py @@ -1,9 +1,8 @@ -from sqlite3 import Row - -import shortuuid # type: ignore -from fastapi.param_functions import Query +import shortuuid +from fastapi import Query from lnurl import Lnurl, LnurlWithdrawResponse -from lnurl import encode as lnurl_encode # type: ignore +from lnurl import encode as lnurl_encode +from lnurl.models import ClearnetUrl, MilliSatoshi from pydantic import BaseModel from starlette.requests import Request @@ -67,18 +66,14 @@ class WithdrawLink(BaseModel): name="withdraw.api_lnurl_callback", unique_hash=self.unique_hash ) return LnurlWithdrawResponse( - callback=url, + callback=ClearnetUrl(url, scheme="https"), k1=self.k1, - min_withdrawable=self.min_withdrawable * 1000, - max_withdrawable=self.max_withdrawable * 1000, - default_description=self.title, + minWithdrawable=MilliSatoshi(self.min_withdrawable * 1000), + maxWithdrawable=MilliSatoshi(self.max_withdrawable * 1000), + defaultDescription=self.title, ) class HashCheck(BaseModel): - id: str - lnurl_id: str - - @classmethod - def from_row(cls, row: Row) -> "Hash": - return cls(**dict(row)) + hash: bool + lnurl: bool diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py index 6d211ed4..e8e5719a 100644 --- a/lnbits/extensions/withdraw/views.py +++ b/lnbits/extensions/withdraw/views.py @@ -2,10 +2,8 @@ from http import HTTPStatus from io import BytesIO import pyqrcode -from fastapi import Request -from fastapi.params import Depends +from fastapi import Depends, HTTPException, Request from fastapi.templating import Jinja2Templates -from starlette.exceptions import HTTPException from starlette.responses import HTMLResponse, StreamingResponse from lnbits.core.models import User diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py index e0d3e56f..525796c9 100644 --- a/lnbits/extensions/withdraw/views_api.py +++ b/lnbits/extensions/withdraw/views_api.py @@ -1,10 +1,7 @@ from http import HTTPStatus -from fastapi.param_functions import Query -from fastapi.params import Depends -from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore -from starlette.exceptions import HTTPException -from starlette.requests import Request +from fastapi import Depends, HTTPException, Query, Request +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl from lnbits.core.crud import get_user from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key @@ -30,7 +27,8 @@ async def api_links( wallet_ids = [wallet.wallet.id] if all_wallets: - wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] try: return [ @@ -47,7 +45,7 @@ async def api_links( @withdraw_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK) async def api_link_retrieve( - link_id, request: Request, wallet: WalletTypeInfo = Depends(get_key_type) + link_id: str, request: Request, wallet: WalletTypeInfo = Depends(get_key_type) ): link = await get_withdraw_link(link_id, 0) @@ -68,7 +66,7 @@ async def api_link_retrieve( async def api_link_create_or_update( req: Request, data: CreateWithdrawData, - link_id: str = None, + link_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key), ): if data.uses > 250: @@ -85,14 +83,6 @@ async def api_link_create_or_update( status_code=HTTPStatus.BAD_REQUEST, ) - usescsv = "" - for i in range(data.uses): - if data.is_unique: - usescsv += "," + str(i + 1) - else: - usescsv += "," + str(1) - usescsv = usescsv[1:] - if link_id: link = await get_withdraw_link(link_id, 0) if not link: @@ -103,13 +93,10 @@ async def api_link_create_or_update( raise HTTPException( detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN ) - link = await update_withdraw_link( - link_id, **data.dict(), usescsv=usescsv, used=0 - ) + link = await update_withdraw_link(link_id, **data.dict()) else: - link = await create_withdraw_link( - wallet_id=wallet.wallet.id, data=data, usescsv=usescsv - ) + link = await create_withdraw_link(wallet_id=wallet.wallet.id, data=data) + assert link return {**link.dict(), **{"lnurl": link.lnurl(req)}} @@ -131,9 +118,11 @@ async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admi return {"success": True} -@withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK) -async def api_hash_retrieve( - the_hash, lnurl_id, wallet: WalletTypeInfo = Depends(get_key_type) -): +@withdraw_ext.get( + "/api/v1/links/{the_hash}/{lnurl_id}", + status_code=HTTPStatus.OK, + dependencies=[Depends(get_key_type)], +) +async def api_hash_retrieve(the_hash, lnurl_id): hashCheck = await get_hash_check(the_hash, lnurl_id) return hashCheck diff --git a/pyproject.toml b/pyproject.toml index 186e2123..03dbbc8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,20 +91,9 @@ files = "lnbits" exclude = """(?x)( ^lnbits/extensions/bleskomat. | ^lnbits/extensions/boltz. - | ^lnbits/extensions/boltcards. - | ^lnbits/extensions/gerty. - | ^lnbits/extensions/invoices. | ^lnbits/extensions/livestream. - | ^lnbits/extensions/lnaddress. - | ^lnbits/extensions/lndhub. | ^lnbits/extensions/lnurldevice. - | ^lnbits/extensions/lnurlp. - | ^lnbits/extensions/offlineshop. - | ^lnbits/extensions/satspay. - | ^lnbits/extensions/streamalerts. - | ^lnbits/extensions/tpos. | ^lnbits/extensions/watchonly. - | ^lnbits/extensions/withdraw. | ^lnbits/wallets/lnd_grpc_files. )"""