Merge branch 'main' into fix/paymentChain_splitPayment

This commit is contained in:
Tiago Vasconcelos 2023-01-05 12:25:28 +00:00
commit 954bbc6de4
98 changed files with 5964 additions and 1302 deletions

View File

@ -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]

View File

@ -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"

View File

@ -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 []

View File

@ -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"]])

View File

@ -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))

View File

@ -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(

View File

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

View File

@ -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)]

View File

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

View File

@ -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:

View File

@ -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",

View File

@ -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 = ""

View File

@ -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])

View File

@ -1,5 +1,4 @@
from sqlite3 import Row
from typing import Optional
from fastapi import Query
from pydantic import BaseModel

View File

@ -32,7 +32,10 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
</q-card>
</div>
<div class="q-pa-md row items-start q-gutter-md" v-if="lnbits_wallets_balance">
<div
class="q-pa-md row items-start q-gutter-md"
v-if="lnbits_wallets_balance[0]"
>
<q-card
class="q-pa-sm"
v-for="(wallet, t) in lnbits_wallets_balance"
@ -49,7 +52,7 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
<div
class="q-pa-md row items-start q-gutter-md"
v-if="dashboard_onchain || dashboard_mining || lightning_dashboard"
v-if="dashboard_onchain[0] || dashboard_mining[0] || lightning_dashboard[0] || url_checker[0]"
>
<q-card
class="q-pa-sm"
@ -67,7 +70,7 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
</q-card-section>
</q-card>
<q-card class="q-pa-sm" v-if="dashboard_mining" unelevated class="q-pa-sm">
<q-card class="q-pa-sm" v-if="dashboard_mining[0]" unelevated class="q-pa-sm">
<q-card-section>
<div class="text-h6">Mining</div>
</q-card-section>
@ -78,7 +81,12 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
</q-card-section>
</q-card>
<q-card class="q-pa-sm" v-if="lightning_dashboard" unelevated class="q-pa-sm">
<q-card
class="q-pa-sm"
v-if="lightning_dashboard[0]"
unelevated
class="q-pa-sm"
>
<q-card-section>
<div class="text-h6">Lightning (Last 7 days)</div>
</q-card-section>
@ -88,7 +96,6 @@ gertyname }}{% endraw %}{% endblock %}{% block page %} {% raw %}
</p>
</q-card-section>
</q-card>
<q-card class="q-pa-sm" v-if="url_checker" unelevated class="q-pa-sm">
<q-card-section>
<div class="text-h6">Servers to check</div>
@ -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++) {

View File

@ -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")

View File

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

View File

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

View File

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

View File

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

View File

@ -257,7 +257,7 @@ block page %}
>
<q-responsive :ratio="1" class="q-mx-xs">
<qrcode
:value="'lightning:' + qrCodeDialog.data.payment_request.toUpperCase()"
:value="'lightning:' + qrCodeDialog.data.payment_request"
:options="{width: 400}"
class="rounded-borders"
></qrcode>

View File

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

View File

@ -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)

View File

@ -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)

View File

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

View File

@ -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(

View File

@ -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=[])

View File

@ -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):

View File

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

View File

@ -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(

View File

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

View File

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

View File

@ -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(

View File

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

View File

@ -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,)

View File

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

View File

@ -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")

View File

@ -26,7 +26,7 @@ class CreatePayLinkData(BaseModel):
class PayLink(BaseModel):
id: int
id: str
wallet: str
description: str
min: float

View File

@ -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,

View File

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

View File

@ -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)}

View File

@ -1,3 +0,0 @@
# LNURLPayOut
## Auto-dump a wallets funds to an LNURLpay

View File

@ -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))

View File

@ -1,6 +0,0 @@
{
"name": "LNURLPayout",
"short_description": "Autodump wallet funds to LNURLpay",
"icon": "exit_to_app",
"contributors": ["arcbtc","talvasconcelos"]
}

View File

@ -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,)
)

View File

@ -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
);
"""
)

View File

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

View File

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

View File

@ -1,119 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/lnurlpayout"></q-btn>
<q-expansion-item group="api" dense expand-separator label="List lnurlpayout">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/lnurlpayout/api/v1/lnurlpayouts</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;lnurlpayout_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create a lnurlpayout"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/lnurlpayout/api/v1/lnurlpayouts</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "currency": &lt;string*ie USD*&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"currency": &lt;string&gt;, "id": &lt;string&gt;, "name":
&lt;string&gt;, "wallet": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -d
'{"name": &lt;string&gt;, "currency": &lt;string&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a lnurlpayout"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/lnurlpayout/api/v1/lnurlpayouts/&lt;lnurlpayout_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}lnurlpayout/api/v1/lnurlpayouts/&lt;lnurlpayout_id&gt; -H
"X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check lnurlpayout"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/lnurlpayout/api/v1/lnurlpayouts/&lt;lnurlpayout_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;lnurlpayout_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}lnurlpayout/api/v1/lnurlpayouts/&lt;lnurlpayout_id&gt; -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -1,271 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New LNURLPayout</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">LNURLPayout</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="lnurlpayouts"
row-key="id"
:columns="lnurlpayoutsTable.columns"
:pagination.sync="lnurlpayoutsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label}}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
<a
class="text-secondary"
v-if="col.label == 'LNURLPay'"
@click="copyText(col.value)"
><q-tooltip>Click to copy LNURL</q-tooltip>{{
col.value.substring(0, 40) }}...</a
>
<div v-else-if="col.label == 'Threshold'">
{{ col.value }} Sats
</div>
<div v-else>{{ col.value.substring(0, 40) }}</div>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deletelnurlpayout(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} LNURLPayout extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "lnurlpayout/_api_docs.html" %}
<q-separator></q-separator>
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="createlnurlpayout" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.title"
label="Title"
placeholder="Title"
type="text"
></q-input>
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.lnurlpay"
label="LNURLPay"
placeholder="LNURLPay"
type="text"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.threshold"
label="Threshold (100k sats max)"
placeholder="Threshold"
type="number"
max="100000"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.threshold == null"
type="submit"
>Create LNURLPayout</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var maplnurlpayout = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.lnurlpayout = ['/lnurlpayout/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
lnurlpayouts: [],
lnurlpayoutsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'title', align: 'left', label: 'Title', field: 'title'},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
{
name: 'lnurlpay',
align: 'left',
label: 'LNURLPay',
field: 'lnurlpay'
},
{
name: 'threshold',
align: 'left',
label: 'Threshold',
field: 'threshold'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
},
getlnurlpayouts: function () {
var self = this
LNbits.api
.request(
'GET',
'/lnurlpayout/api/v1/lnurlpayouts?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.lnurlpayouts = response.data.map(function (obj) {
return maplnurlpayout(obj)
})
})
},
createlnurlpayout: function () {
var data = {
title: this.formDialog.data.title,
lnurlpay: this.formDialog.data.lnurlpay,
threshold: this.formDialog.data.threshold
}
var self = this
LNbits.api
.request(
'POST',
'/lnurlpayout/api/v1/lnurlpayouts',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
console.log(data)
self.lnurlpayouts.push(maplnurlpayout(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deletelnurlpayout: function (lnurlpayoutId) {
var self = this
var lnurlpayout = _.findWhere(this.lnurlpayouts, {id: lnurlpayoutId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this lnurlpayout?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/lnurlpayout/api/v1/lnurlpayouts/' + lnurlpayoutId,
_.findWhere(self.g.user.wallets, {id: lnurlpayout.wallet})
.adminkey
)
.then(function (response) {
self.lnurlpayouts = _.reject(self.lnurlpayouts, function (obj) {
return obj.id == lnurlpayoutId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(
this.lnurlpayoutsTable.columns,
this.lnurlpayouts
)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getlnurlpayouts()
}
}
})
</script>
{% endblock %}

View File

@ -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()}
)

View File

@ -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])

View File

@ -0,0 +1,9 @@
<h1>Market</h1>
<h2>A movable market stand</h2>
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.
<img src="https://i.imgur.com/P1tvBSG.png">
<h2>API endpoints</h2>
<code>curl -X GET http://YOUR-TOR-ADDRESS</code>

View File

@ -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))

View File

@ -0,0 +1,6 @@
{
"name": "Marketplace",
"short_description": "Webshop/market on LNbits",
"tile": "/market/static/images/bitcoin-shop.png",
"contributors": ["benarc", "talvasconcelos"]
}

View File

@ -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,
),
)

View File

@ -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)"
)

View File

@ -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(...)

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -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)

View File

@ -0,0 +1,128 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="Setup guide"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
LNbits Market (Nostr support coming soon)
</h5>
<ol>
<li>Create Shipping Zones you're willing to ship to</li>
<li>Create a Stall to list yiur products on</li>
<li>Create products to put on the Stall</li>
<li>Take orders</li>
<li>Includes chat support!</li>
</ol>
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.
<br />
<small>
Created by,
<a href="https://github.com/talvasconcelos">Tal Vasconcelos</a>,
<a href="https://github.com/benarc">Ben Arc</a></small
>
<!-- </p> -->
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item
group="api"
dense
expand-separator
label="Get prodcuts, categorised by wallet"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/market/api/v1/stall/products/&lt;relay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>Product JSON list</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}api/v1/stall/products/&lt;relay_id&gt;</code
>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get invoice for product"
>
<q-card>
<q-card-section>
<code
><span class="text-light-green">POST</span>
/market/api/v1/stall/order/&lt;relay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"id": &lt;string&gt;, "address": &lt;string&gt;, "shippingzone":
&lt;integer&gt;, "email": &lt;string&gt;, "quantity":
&lt;integer&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"checking_id": &lt;string&gt;,"payment_request":
&lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root
}}api/v1/stall/order/&lt;relay_id&gt; -d '{"id": &lt;product_id&&gt;,
"email": &lt;customer_email&gt;, "address": &lt;customer_address&gt;,
"quantity": 2, "shippingzone": 1}' -H "Content-type: application/json"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check a product has been shipped"
class="q-mb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-light-blue">GET</span>
/market/api/v1/stall/checkshipped/&lt;checking_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"shipped": &lt;boolean&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root
}}api/v1/stall/checkshipped/&lt;checking_id&gt; -H "Content-type:
application/json"</code
>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,58 @@
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">Messages</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
</q-card-section>
<q-card-section>
<q-select
v-model="customerKey"
:options="Object.keys(messages).map(k => ({label: `${k.slice(0, 25)}...`, value: k}))"
label="Customers"
@input="chatRoom(customerKey)"
emit-value
></q-select>
</q-card-section>
<q-card-section>
<div class="chat-container" ref="chatCard">
<div class="chat-box">
<!-- <p v-if="Object.keys(messages).length === 0">No messages yet</p> -->
<div class="chat-messages">
<q-chat-message
:key="index"
v-for="(message, index) in orderMessages"
:name="message.pubkey == keys.pubkey ? 'me' : 'customer'"
:text="[message.msg]"
:sent="message.pubkey == keys.pubkey ? true : false"
:bg-color="message.pubkey == keys.pubkey ? 'white' : 'light-green-2'"
/>
</div>
</div>
<q-card-section>
<q-form @submit="sendMessage" class="full-width chat-input">
<q-input
ref="newMessage"
v-model="newMessage"
placeholder="Message"
class="full-width"
dense
outlined
@click="checkWebSocket"
>
<template>
<q-btn
round
dense
flat
type="submit"
icon="send"
color="primary"
/>
</template>
</q-input>
</q-form>
</q-card-section>
</div>
</q-card-section>
</q-card>

View File

@ -0,0 +1,393 @@
<!-- PRODUCT DIALOG -->
<q-dialog v-model="productDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendProductFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="productDialog.data.stall"
:options="stalls.map(s => ({label: s.name, value: s.id}))"
label="Stall"
>
</q-select>
<q-input
filled
dense
v-model.trim="productDialog.data.product"
label="Product"
></q-input>
<q-input
filled
dense
v-model.trim="productDialog.data.description"
label="Description"
></q-input>
<!-- <div class="row"> -->
<!-- <div class="col-5">
<q-select
filled
dense
multiple
v-model.trim="productDialog.data.categories"
:options="categories"
label="Categories"
class="q-pr-sm"
></q-select>
</div> -->
<!-- <div class="col-7"> -->
<q-select
filled
multiple
dense
emit-value
v-model.trim="productDialog.data.categories"
use-input
use-chips
multiple
hide-dropdown-icon
input-debounce="0"
new-value-mode="add-unique"
label="Categories"
placeholder="crafts,robots,etc"
hint="Hit Enter to add"
></q-select>
<!-- </div> -->
<!-- </div> -->
<q-file
class="q-pr-md"
filled
dense
capture="environment"
accept="image/jpeg, image/png"
:max-file-size="3*1024**2"
label="Small image (optional)"
clearable
@input="imageAdded"
@clear="imageCleared"
>
<template v-if="productDialog.data.image" v-slot:before>
<img style="height: 1em" :src="productDialog.data.image" />
</template>
<template v-if="productDialog.data.image" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="imageCleared"
class="cursor-pointer"
/>
</template>
</q-file>
<q-input
filled
dense
v-model.number="productDialog.data.price"
type="number"
:label="'Price (' + currencies.unit + ') *'"
:mask="currencies.unit != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="currencies.unit != 'sat' ? '0.01' : '1'"
></q-input>
<q-input
filled
dense
v-model.number="productDialog.data.quantity"
type="number"
label="Quantity"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="productDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Product</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="productDialog.data.price == null
|| productDialog.data.product == null
|| productDialog.data.description == null
|| productDialog.data.quantity == null"
type="submit"
>Create Product</q-btn
>
<q-btn
v-close-popup
flat
@click="resetDialog('productDialog')"
color="grey"
class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- ZONE DIALOG -->
<q-dialog v-model="zoneDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendZoneFormData" class="q-gutter-md">
<q-select
filled
dense
multiple
:options="shippingZoneOptions"
label="Countries"
v-model.trim="zoneDialog.data.countries"
></q-select>
<q-input
filled
dense
:label="'Amount (' + currencies.unit + ') *'"
:mask="currencies.unit != 'sat' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:step="currencies.unit != 'sat' ? '0.01' : '1'"
type="number"
v-model.trim="zoneDialog.data.cost"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="zoneDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Shipping Zone</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="zoneDialog.data.countries == null
|| zoneDialog.data.cost == null"
type="submit"
>Create Shipping Zone</q-btn
>
<q-btn
v-close-popup
flat
@click="resetDialog('zoneDialog')"
color="grey"
class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- MARKETPLACE/market DIALOG -->
<q-dialog v-model="marketDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendMarketplaceFormData" class="q-gutter-md">
<q-toggle
label="Activate marketplace"
color="primary"
v-model="marketDialog.data.activate"
></q-toggle>
<q-select
filled
multiple
emit-value
:options="stalls.map(s => ({label: s.name, value: s.id}))"
label="Stalls"
v-model="marketDialog.data.stalls"
map-options
></q-select>
<q-input
filled
dense
v-model.trim="marketDialog.data.name"
label="Name"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="marketDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Marketplace</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="marketDialog.data.activate == null
|| marketDialog.data.stalls == null"
type="submit"
>Launch Marketplace</q-btn
>
<q-btn
v-close-popup
flat
@click="resetDialog('marketDialog')"
color="grey"
class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- STALL/STORE DIALOG -->
<q-dialog v-model="stallDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="sendStallFormData" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="stallDialog.data.name"
label="Name"
></q-input>
<q-select
filled
dense
emit-value
v-model="stallDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
v-if="diagonAlley"
v-if="keys"
filled
dense
v-model.trim="stallDialog.data.publickey"
label="Public Key"
></q-input>
<q-input
v-if="diagonAlley"
v-if="keys"
filled
dense
v-model.trim="stallDialog.data.privatekey"
label="Private Key"
></q-input>
<!-- NOSTR -->
<div v-if="diagonAlley" class="row">
<div class="col-5">
<q-btn unelevated @click="generateKeys" color="primary"
>Generate keys</q-btn
>
</div>
<div class="col-5">
<q-btn unelevated @click="restoreKeys" color="primary"
>Restore keys</q-btn
>
</div>
</div>
<q-select
:options="zoneOptions"
filled
dense
multiple
v-model.trim="stallDialog.data.shippingzones"
label="Shipping Zones"
></q-select>
<q-select
v-if="diagonAlley"
:options="relayOptions"
filled
dense
multiple
v-model.trim="stallDialog.data.relays"
label="Relays"
></q-select>
<q-input
v-if="diagonAlley"
filled
dense
v-model.trim="stallDialog.data.crelays"
label="Custom relays (seperate by comma)"
></q-input>
<q-input
v-if="diagonAlley"
filled
dense
v-model.trim="stallDialog.data.nostrMarkets"
label="Nostr market public keys (seperate by comma)"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="stallDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Stall</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="stallDialog.data.wallet == null
|| stallDialog.data.shippingzones == null"
type="submit"
>Create Stall</q-btn
>
<q-btn
v-close-popup
flat
@click="resetDialog('stallDialog')"
color="grey"
class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- ONBOARDING DIALOG -->
<q-dialog v-model="onboarding.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">How to use Market</h6>
<q-stepper v-model="step" color="primary" vertical animated>
<q-step
:name="1"
title="Create a Shipping Zone"
icon="settings"
:done="step > 1"
>
Create Shipping Zones you're willing to ship to. You can define
different values for different zones.
<q-stepper-navigation>
<q-btn @click="step = step + 1" color="primary" label="Next" />
</q-stepper-navigation>
</q-step>
<q-step
:name="2"
title="Create a Stall"
icon="create_new_folder"
:done="step > 2"
>
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.
<q-stepper-navigation>
<q-btn @click="step = step + 1" color="primary" label="Next" />
</q-stepper-navigation>
</q-step>
<q-step :name="3" title="Create Products" icon="assignment">
Create your products, add a small description and an image. Choose to
what stall, if you have more than one, it belongs to
<q-stepper-navigation>
<q-btn @click="onboarding.finish" color="primary" label="Finish" />
</q-stepper-navigation>
<div>
<q-checkbox v-model="onboarding.showAgain" label="Show this again?" />
</div>
</q-step>
</q-stepper>
</q-card>
</q-dialog>

View File

@ -0,0 +1,440 @@
<q-card>
<!-- ORDERS TABLE -->
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Orders</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportOrdersCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="orders"
row-key="id"
:columns="ordersTable.columns"
:pagination.sync="ordersTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.expand = !props.expand"
:icon="props.expand ? 'remove' : 'add'"
/>
</q-td>
<q-td auto-width>
<q-btn
size="sm"
color="green"
dense
icon="chat"
@click="chatRoom(props.row.invoiceid)"
>
<q-badge
v-if="props.row.unread"
color="red"
rounded
floating
style="padding: 6px; border-radius: 6px"
/>
</q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="shipOrder(props.row.id)"
icon="add_marketping_cart"
color="green"
>
<q-tooltip> Product shipped? </q-tooltip>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteOrder(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<template>
<div class="q-pa-md">
<q-list>
<q-item-label header>Order Details</q-item-label>
<q-item v-for="col in props.row.details" :key="col.id">
<q-item-section>
<q-item-label>Products</q-item-label>
<q-item-label caption
>{{ products.length && (_.findWhere(products, {id:
col.product_id})).product }}</q-item-label
>
<q-item-label caption
>Quantity: {{ col.quantity }}</q-item-label
>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>Shipping to</q-item-label>
<q-item-label caption
>{{ props.row.address }}</q-item-label
>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>User info</q-item-label>
<q-item-label caption v-if="props.row.username"
>{{ props.row.username }}</q-item-label
>
<q-item-label caption>{{ props.row.email }}</q-item-label>
<q-item-label caption v-if="props.row.pubkey"
>{{ props.row.pubkey }}</q-item-label
>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>Total</q-item-label>
<q-item-label>{{ props.row.total }}</q-item-label>
<!-- <q-icon name="star" color="yellow" /> -->
</q-item-section>
</q-item>
</q-list>
</div>
</template>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<!-- PRODUCTS TABLE -->
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">
Products
<span v-if="stalls.length > 0" class="q-px-sm">
<q-btn
round
color="primary"
icon="add"
size="sm"
@click="productDialog.show = true"
/>
<q-tooltip> Add a product </q-tooltip>
</span>
</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportProductsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="products"
row-key="id"
:columns="productsTable.columns"
:pagination.sync="productsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
disabled
unelevated
dense
size="xs"
icon="add_marketping_cart"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.wallet"
target="_blank"
></q-btn>
<q-tooltip> Link to pass to stall relay </q-tooltip>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td class="text-center" auto-width>
<img
v-if="props.row.image"
:src="props.row.image"
style="height: 1.5em"
/>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openProductUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteProduct(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<!-- STALLS TABLE -->
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Market Stalls</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportStallsCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="stalls"
row-key="id"
:columns="stallTable.columns"
:pagination.sync="stallTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="storefront"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/market/stalls/' + props.row.id"
target="_blank"
></q-btn>
<q-tooltip> Stall simple UI marketping cart </q-tooltip>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openStallUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteStall(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card v-if="markets.length">
<!-- MARKETPLACES TABLE -->
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Marketplaces</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportStallsCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="markets"
row-key="id"
:columns="marketTable.columns"
:pagination.sync="marketTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="storefront"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'/market/market/' + props.row.id"
target="_blank"
></q-btn>
<q-tooltip> Link to pass to stall relay </q-tooltip>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.name == 'stalls' ? stallName(col.value) : col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openMarketUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteMarket(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<!-- ZONES TABLE -->
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Shipping Zones</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportZonesCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="zones"
row-key="id"
:columns="zonesTable.columns"
:pagination.sync="zonesTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openZoneUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deleteZone(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,175 @@
{% extends "public.html" %} {% block page %}
<div class="row q-mb-md">
<div class="col-12 q-gutter-y-md">
<q-toolbar class="row">
<div class="col">
<q-toolbar-title> Market: {{ market.name }} </q-toolbar-title>
</div>
<div class="col q-mx-md">
<q-input
class="float-left full-width q-ml-md"
standout
square
dense
outlined
clearable
v-model.trim="searchText"
label="Search for products"
>
<template v-slot:append>
<q-icon v-if="!searchText" name="search" />
</template>
</q-input>
</div>
</q-toolbar>
</div>
</div>
<div class="row q-col-gutter-md">
<div
class="col-xs-12 col-sm-6 col-md-4 col-lg-3"
v-for="item in filterProducts"
:key="item.id"
>
<q-card class="card--product">
{% raw %}
<q-img
:src="item.image ? item.image : '/market/static/images/placeholder.png'"
alt="Product Image"
loading="lazy"
spinner-color="white"
fit="contain"
height="300px"
></q-img>
<q-card-section class="q-pb-xs q-pt-md">
<div class="row no-wrap items-center">
<div class="col text-subtitle2 ellipsis-2-lines">
{{ item.product }}
</div>
</div>
<!-- <q-rating v-model="stars" color="orange" :max="5" readonly size="17px"></q-rating> -->
</q-card-section>
<q-card-section class="q-py-sm">
<div>
<div class="text-caption text-weight-bolder">
{{ item.stallName }}
</div>
<span v-if="item.currency == 'sat'">
<span class="text-h6">{{ item.price }} sats</span
><span class="q-ml-sm text-grey-6"
>BTC {{ (item.price / 1e8).toFixed(8) }}</span
>
</span>
<span v-else>
<span class="text-h6"
>{{ getAmountFormated(item.price, item.currency) }}</span
>
<span v-if="exchangeRates" class="q-ml-sm text-grey-6"
>({{ getValueInSats(item.price, item.currency) }} sats)</span
>
</span>
<span
class="q-ml-md text-caption text-green-8 text-weight-bolder q-mt-md"
>{{item.quantity}} left</span
>
</div>
<div v-if="item.categories" class="text-subtitle1">
<q-chip v-for="(cat, i) in item.categories.split(',')" :key="i" dense
>{{cat}}</q-chip
>
</div>
<div
class="text-caption text-grey ellipsis-2-lines"
style="min-height: 40px"
>
<p v-if="item.description">{{ item.description }}</p>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-actions>
<span>Stall: {{ item.stallName }}</span>
<q-btn
flat
class="text-weight-bold text-capitalize q-ml-auto"
dense
color="primary"
type="a"
:href="'/market/stalls/' + item.stall"
target="_blank"
>
Visit Stall
</q-btn>
</q-card-actions>
{% endraw %}
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
stalls: null,
products: [],
searchText: null,
exchangeRates: null
}
},
computed: {
filterProducts() {
if (!this.searchText || this.searchText.length < 2) return this.products
return this.products.filter(p => {
return (
p.product.includes(this.searchText) ||
p.description.includes(this.searchText) ||
p.categories.includes(this.searchText)
)
})
}
},
methods: {
async getRates() {
let noFiat = this.stalls.map(s => s.currency).every(c => c == 'sat')
if (noFiat) return
try {
let rates = await axios.get('https://api.opennode.co/v1/rates')
this.exchangeRates = rates.data.data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getValueInSats(amount, unit = 'USD') {
if (!this.exchangeRates) return 0
return Math.ceil(
(amount / this.exchangeRates[`BTC${unit}`][unit]) * 1e8
)
},
getAmountFormated(amount, unit = 'USD') {
return LNbits.utils.formatCurrency(amount, unit)
}
},
async created() {
this.stalls = JSON.parse('{{ stalls | tojson }}')
let products = JSON.parse('{{ products | tojson }}')
this.products = products.map(obj => {
let stall = this.stalls.find(s => s.id == obj.stall)
obj.currency = stall.currency
if (obj.currency != 'sat') {
obj.price = parseFloat((obj.price / 100).toFixed(2))
}
obj.stallName = stall.name
return obj
})
await this.getRates()
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,564 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md flex">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card>
<div class="chat-container q-pa-md">
<div class="chat-box">
<!-- <p v-if="Object.keys(messages).length === 0">No messages yet</p> -->
<div class="chat-messages">
<q-chat-message
:key="index"
v-for="(message, index) in messages"
:name="message.pubkey == user.keys.publickey ? 'me' : 'merchant'"
:text="[message.msg]"
:sent="message.pubkey == user.keys.publickey ? true : false"
:bg-color="message.pubkey == user.keys.publickey ? 'white' : 'light-green-2'"
/>
</div>
</div>
<q-form @submit="sendMessage" class="full-width chat-input">
<q-input
ref="newMessage"
v-model="newMessage"
placeholder="Message"
class="full-width"
dense
outlined
@click="checkWebSocket"
>
<template>
<q-btn
round
dense
flat
type="submit"
icon="send"
color="primary"
/>
</template>
</q-input>
</q-form>
</div>
</q-card>
</div>
<div class="col-12 col-md-5 col-lg-6 q-gutter-y-md">
<q-card>
<q-card-section>
{% raw %}
<h6 class="text-subtitle1 q-my-none">{{ stall.name }}</h6>
<p @click="copyText(stall.publickey)" style="width: max-content">
Public Key: {{ sliceKey(stall.publickey) }}
<q-tooltip>Click to copy</q-tooltip>
</p>
{% endraw %}
</q-card-section>
<q-card-section v-if="user">
<q-form @submit="" class="q-gutter-md">
<!-- <q-select
filled
dense
emit-value
v-model="model"
:options="mockMerch"
label="Merchant"
hint="Select a merchant you've opened an order to"
></q-select>
<br /> -->
<q-select
filled
dense
emit-value
v-model="selectedOrder"
:options="Object.keys(user.orders).map(o => ({label: `${o.slice(0, 25)}...`, value: o}))"
label="Order"
hint="Select an order from this merchant"
@input="val => { changeOrder() }"
emit-value
></q-select>
</q-form>
</q-card-section>
<q-card-section>
<q-list>
{% raw %}
<q-item clickable :key="p.id" v-for="p in products">
<q-item-section side>
<span>{{p.quantity}} x </span>
</q-item-section>
<q-item-section avatar>
<q-avatar color="primary">
<img size="sm" :src="p.image" />
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ p.name }}</q-item-label>
</q-item-section>
<q-item-section side>
<span v-if="stall.currency != 'sat'"
>{{ getAmountFormated(p.price) }}</span
>
<span v-else> {{p.price}} sats</span>
</q-item-section>
</q-item>
{% endraw %}
</q-list>
</q-card-section>
<q-card-section>
<q-separator></q-separator>
<q-list>
<q-expansion-item group="extras" icon="vpn_key" label="Keys"
><p>
Bellow are the keys needed to contact the merchant. They are
stored in the browser!
</p>
<div v-if="user?.keys" class="row q-col-gutter-md">
<div
class="col-12 col-sm-6"
v-for="type in ['publickey', 'privatekey']"
v-bind:key="type"
>
<div class="text-center q-mb-lg">
{% raw %}
<q-responsive
:ratio="1"
class="q-mx-auto"
style="max-width: 250px"
>
<qrcode
:value="user.keys[type]"
:options="{width: 500}"
class="rounded-borders"
></qrcode>
<q-tooltip>{{ user.keys[type] }}</q-tooltip>
</q-responsive>
<p>
{{ type == 'publickey' ? 'Public Key' : 'Private Key' }}
</p>
{% endraw %}
</div>
</div>
</div>
<q-separator></q-separator>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="downloadKeys"
>Backup keys
<q-tooltip>Download your keys</q-tooltip>
</q-btn>
<q-btn
outline
color="grey"
class="q-mx-sm"
@click="keysDialog.show = true"
:disabled="this.user.keys"
>Restore keys
<q-tooltip>Restore keys</q-tooltip>
</q-btn>
<q-btn
@click="deleteData"
v-close-popup
flat
color="grey"
class="q-ml-auto"
>Delete data
<q-tooltip>Delete all data from browser</q-tooltip>
</q-btn>
</div>
</q-expansion-item>
</q-list>
<q-expansion-item icon="qr_code" label="Export page">
<p>Export, or send, this page to another device</p>
<div class="text-center q-mb-lg">
<q-responsive
:ratio="1"
class="q-my-xl q-mx-auto"
style="max-width: 250px"
@click="copyText(exportURL)"
>
<qrcode
:value="exportURL"
:options="{width: 500}"
class="rounded-borders"
></qrcode>
<q-tooltip>Click to copy</q-tooltip>
</q-responsive>
</div>
<div class="row q-mt-lg">
<q-btn
@click="copyText(exportURL)"
v-close-popup
flat
color="grey"
class="q-ml-auto"
>Copy URL
<q-tooltip
>Export, or send, this page to another device</q-tooltip
>
</q-btn>
</div>
</q-expansion-item>
</q-card-section>
</q-card>
</div>
<!-- RESTORE KEYS DIALOG -->
<q-dialog
v-if="diagonalley"
v-model="keysDialog.show"
position="top"
@hide="clearRestoreKeyDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> </q-card>
<q-card class="q-pa-lg lnbits__dialog-card">
<q-form @submit="restoreKeys" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="keysDialog.data.publickey"
label="Public Key"
></q-input>
<q-input
filled
dense
v-model.trim="keysDialog.data.privatekey"
label="Private Key *optional"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="keysDialog.data.publickey == null"
type="submit"
label="Submit"
></q-btn>
<q-btn
v-close-popup
flat
@click="clearRestoreKeyDialog"
color="grey"
class="q-ml-auto"
label="Cancel"
></q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- ONBOARDING DIALOG -->
<q-dialog v-model="lnbitsBookmark.show">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">Bookmark this page</h6>
<p>
Don't forget to bookmark this page to be able to check on your order!
</p>
<p>
You can backup your keys, and export the page to another device also.
</p>
<div class="row q-mt-lg">
<q-btn
v-close-popup
flat
@click="lnbitsBookmark.finish"
color="grey"
class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script src="https://unpkg.com/nostr-tools/lib/nostr.bundle.js"></script>
<script>
const mapChatMsg = msg => {
let obj = {}
obj.timestamp = {
msg: msg,
pubkey: pubkey
}
return obj
}
const mapProductsItems = obj => {
obj.price = (obj.price / 100).toFixed(2)
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
const nostr = window.NostrTools
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
lnbitsBookmark: {
show: true,
finish: () => {
this.$q.localStorage.set('lnbits.marketbookmark', false)
this.lnbitsBookmark.show = false
}
},
newMessage: '',
showMessages: false,
messages: {},
stall: null,
selectedOrder: null,
diagonalley: false,
products: [],
orders: [],
user: {
keys: {},
orders: {}
},
keysDialog: {
show: false,
data: {}
}
}
},
computed: {
exportURL() {
return (
'{{request.url}}' +
`&keys=${this.user.keys.publickey},${this.user.keys.privatekey}`
)
}
},
methods: {
getAmountFormated(amount) {
return LNbits.utils.formatCurrency(amount, this.stall.currency)
},
clearMessage() {
this.newMessage = ''
this.$refs.newMessage.focus()
},
clearRestoreKeyDialog() {
this.keysDialog = {
show: false,
data: {}
}
},
sendMessage() {
let message = {
msg: this.newMessage,
pubkey: this.user.keys.publickey
}
this.ws.send(JSON.stringify(message))
this.clearMessage()
},
sliceKey(key) {
if (!key) return ''
return `${key.slice(0, 4)}...${key.slice(-4)}`
},
downloadKeys() {
const file = new File(
[JSON.stringify(this.user.keys)],
'backup_keys.json',
{
type: 'text/json'
}
)
const link = document.createElement('a')
const url = URL.createObjectURL(file)
link.href = url
link.download = file.name
link.click()
window.URL.revokeObjectURL(url)
},
restoreKeys() {
this.user.keys = this.keysDialog.data
let data = this.$q.localStorage.getItem(`lnbits.market.data`)
this.$q.localStorage.set(`lnbits.market.data`, {
...data,
keys: this.user.keys
})
this.clearRestoreKeyDialog()
},
deleteData() {
LNbits.utils
.confirmDialog('Are you sure you want to delete your stored data?')
.onOk(() => {
this.$q.localStorage.remove('lnbits.market.data')
this.user = null
})
},
generateKeys() {
//check if the keys are set
if ('publickey' in this.user.keys && 'privatekey' in this.user.keys)
return
const privkey = nostr.generatePrivateKey()
const pubkey = nostr.getPublicKey(privkey)
this.user.keys = {
privatekey: privkey,
publickey: pubkey
}
},
async getMessages(room_name, all = false) {
await LNbits.api
.request(
'GET',
`/market/api/v1/chat/messages/${room_name}${
all ? '?all_messages=true' : ''
}`
)
.then(response => {
if (response.data) {
response.data.reverse().map(m => {
this.$set(this.messages, m.timestamp * 1000, {
msg: m.msg,
pubkey: m.pubkey
})
})
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
async changeOrder() {
this.products = this.user.orders[this.selectedOrder]
this.messages = {}
await this.getMessages(this.selectedOrder)
this.startChat(this.selectedOrder)
},
checkWebSocket() {
if (!this.ws) return
if (this.ws.readyState === WebSocket.CLOSED) {
console.log('WebSocket CLOSED: Reopening')
this.ws = new WebSocket(
ws_scheme + location.host + '/market/ws/' + this.selectedOrder
)
}
},
startChat(room_name) {
if (this.ws) {
this.ws.close()
}
if (location.protocol == 'https:') {
ws_scheme = 'wss://'
} else {
ws_scheme = 'ws://'
}
ws = new WebSocket(
ws_scheme + location.host + '/market/ws/' + room_name
)
ws.onmessage = event => {
let event_data = JSON.parse(event.data)
this.$set(this.messages, Date.now(), event_data)
}
this.ws = ws
}
},
async created() {
let showBookmark = this.$q.localStorage.getItem('lnbits.marketbookmark')
this.lnbitsBookmark.show = showBookmark === true || showBookmark == null
let order_details = JSON.parse('{{ order | tojson }}')
let products = JSON.parse('{{ products | tojson }}')
let order_id = '{{ order_id }}'
let hasKeys = Boolean(
JSON.parse('{{ publickey | tojson }}') &&
JSON.parse('{{ privatekey | tojson }}')
)
if (hasKeys) {
this.user.keys = {
privatekey: '{{ privatekey }}',
publickey: '{{ publickey }}'
}
}
this.stall = JSON.parse('{{ stall | tojson }}')
this.products = order_details.map(o => {
let product = products.find(p => p.id == o.product_id)
return {
quantity: o.quantity,
name: product.product,
image: product.image,
price: product.price
}
})
console.log(this.stall)
if (this.stall.currency != 'sat') {
this.products = this.products.map(mapProductsItems)
}
let data = this.$q.localStorage.getItem(`lnbits.market.data`) || false
if (data) {
this.user = data
if (!this.user.orders[`${order_id}`]) {
this.$set(this.user.orders, order_id, this.products)
}
} else {
// generate keys
this.generateKeys()
try {
await LNbits.api.request(
'GET',
`/market/api/v1/order/pubkey/${order_id}/${this.user.keys.publickey}`
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
// populate user data
this.user.orders = {
[`${order_id}`]: this.products
}
}
this.selectedOrder = order_id
await this.getMessages(order_id)
this.$q.localStorage.set(`lnbits.market.data`, this.user)
this.startChat(order_id)
}
})
</script>
<style scoped>
.q-field__native span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-container {
position: relative;
display: grid;
grid-template-rows: 1fr auto;
/*height: calc(100vh - 200px);*/
height: 70vh;
}
.chat-box {
display: flex;
flex-direction: column-reverse;
padding: 1rem;
overflow-y: auto;
margin-left: auto;
width: 50%;
}
.chat-messages {
width: auto;
}
.chat-other {
}
.chat-input {
position: relative;
display: flex;
align-items: end;
margin-top: 1rem;
}
</style>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "public.html" %} {% block page %}
<h1>Product page</h1>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,531 @@
{% extends "public.html" %} {% block page %}
<div class="row q-mb-md">
<div class="col-12 q-gutter-y-md">
<q-toolbar class="row">
<div class="col">
<q-toolbar-title> Stall: {{ stall.name }} </q-toolbar-title>
</div>
<div class="col q-mx-md">
<q-input
class="float-left full-width q-ml-md"
standout
square
dense
outlined
clearable
v-model.trim="searchText"
label="Search for products"
>
<template v-slot:append>
<q-icon v-if="!searchText" name="search" />
</template>
</q-input>
</div>
<q-btn dense round flat icon="shopping_cart">
{% raw %}
<q-badge v-if="cart.size" color="red" class="text-bold" floating>
{{ cart.size }}
</q-badge>
{% endraw %}
<q-menu v-if="cart.size">
<q-list style="min-width: 100px">
{% raw %}
<q-item :key="p.id" v-for="p in cartMenu">
<q-item-section side>
<span>{{p.quantity}} x </span>
</q-item-section>
<q-item-section avatar>
<q-avatar color="primary">
<img
size="sm"
:src="products.find(f => f.id == p.id).image"
/>
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label>{{ p.name }}</q-item-label>
</q-item-section>
<q-item-section side>
<span>
{{unit != 'sat' ? getAmountFormated(p.price) : p.price +
'sats'}}
<q-btn
class="q-ml-md"
round
color="red"
size="xs"
icon="close"
@click="removeFromCart(p)"
/>
</span>
</q-item-section>
</q-item>
{% endraw %}
<q-separator />
</q-list>
<div class="row q-pa-md q-gutter-md">
<q-btn
color="primary"
icon-right="checkout"
label="Checkout"
@click="checkoutDialog.show = true"
></q-btn>
<q-btn
class="q-ml-lg"
flat
color="primary"
label="Reset"
@click="resetCart"
></q-btn>
</div>
</q-menu>
</q-btn>
</q-toolbar>
</div>
</div>
<div class="row q-col-gutter-md">
<div
class="col-xs-12 col-sm-6 col-md-4 col-lg-3"
v-for="item in filterProducts"
:key="item.id"
>
<q-card class="card--product">
{% raw %}
<q-img
:src="item.image ? item.image : '/market/static/images/placeholder.png'"
alt="Product Image"
loading="lazy"
spinner-color="white"
fit="contain"
height="300px"
></q-img>
<q-card-section class="q-pb-xs q-pt-md">
<q-btn
round
:disabled="item.quantity < 1"
color="primary"
icon="shopping_cart"
size="lg"
style="
position: absolute;
top: 0;
right: 0;
transform: translate(-50%, -50%);
"
@click="addToCart(item)"
><q-tooltip> Add to cart </q-tooltip></q-btn
>
<div class="row no-wrap items-center">
<div class="col text-subtitle2 ellipsis-2-lines">
{{ item.product }}
</div>
</div>
<!-- <q-rating v-model="stars" color="orange" :max="5" readonly size="17px"></q-rating> -->
</q-card-section>
<q-card-section class="q-py-sm">
<div>
<span v-if="unit == 'sat'">
<span class="text-h6">{{ item.price }} sats</span
><span class="q-ml-sm text-grey-6"
>BTC {{ (item.price / 1e8).toFixed(8) }}</span
>
</span>
<span v-else>
<span class="text-h6">{{ getAmountFormated(item.price) }}</span>
<span v-if="exchangeRate" class="q-ml-sm text-grey-6"
>({{ getValueInSats(item.price) }} sats)</span
>
</span>
<span
class="q-ml-md text-caption text-green-8 text-weight-bolder q-mt-md"
>{{item.quantity}} left</span
>
</div>
<div v-if="item.categories" class="text-subtitle1">
<q-chip v-for="(cat, i) in item.categories.split(',')" :key="i" dense
>{{cat}}</q-chip
>
</div>
<div
class="text-caption text-grey ellipsis-2-lines"
style="min-height: 40px"
>
<p v-if="item.description">{{ item.description }}</p>
</div>
</q-card-section>
<!-- <q-separator></q-separator>
<q-card-actions>
<q-btn
flat
class="text-weight-bold text-capitalize"
dense
color="primary"
>
View details
</q-btn>
</q-card-actions> -->
{% endraw %}
</q-card>
</div>
<!-- CHECKOUT DIALOG -->
<q-dialog v-model="checkoutDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="placeOrder" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="checkoutDialog.data.username"
label="Name *optional"
></q-input>
<q-input
v-if="diagonalley"
filled
dense
v-model.trim="checkoutDialog.data.pubkey"
label="Public key *optional"
>
<template v-slot:append>
<q-icon @click="getPubkey" name="settings_backup_restore" />
<q-tooltip>Click to restore saved public key</q-tooltip>
</template>
</q-input>
<q-input
filled
dense
v-model.trim="checkoutDialog.data.address"
label="Address"
></q-input>
<!-- <q-input
filled
dense
v-model.trim="checkoutDialog.data.address_2"
label="Address (line 2)"
></q-input> -->
<q-input
v-model="checkoutDialog.data.email"
filled
dense
type="email"
label="Email"
></q-input>
<p>Select the shipping zone:</p>
<div class="row q-mt-lg">
<q-option-group
:options="stall.zones"
type="radio"
emit-value
v-model="checkoutDialog.data.shippingzone"
/>
</div>
<div class="row q-mt-lg">
{% raw %} Total: {{ unit != 'sat' ? getAmountFormated(finalCost) :
finalCost + 'sats' }}
<span v-if="unit != 'sat'" class="q-ml-sm text-grey-6"
>({{ getValueInSats(finalCost) }} sats)</span
>
{% endraw %}
</div>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="checkoutDialog.data.address == null
|| checkoutDialog.data.email == null
|| checkoutDialog.data.shippingzone == null"
type="submit"
>Checkout</q-btn
>
<q-btn
v-close-popup
flat
@click="checkoutDialog = {show: false, data: {pubkey: ''}}"
color="grey"
class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<!-- INVOICE DIALOG -->
<q-dialog
v-model="qrCodeDialog.show"
position="top"
@hide="closeQrCodeDialog"
>
<q-card
v-if="!qrCodeDialog.data.payment_request"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + qrCodeDialog.data.payment_request">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="qrCodeDialog.data.payment_request"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.payment_request)"
>Copy invoice</q-btn
>
<q-btn
@click="closeQrCodeDialog"
v-close-popup
flat
color="grey"
class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %}
<script>
const mapProductsItems = obj => {
obj.price = parseFloat((obj.price / 100).toFixed(2))
return obj
}
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
stall: null,
products: [],
searchText: null,
diagonalley: false,
unit: 'sat',
exchangeRate: 0,
cart: {
total: 0,
size: 0,
products: new Map()
},
cartMenu: [],
checkoutDialog: {
show: false,
data: {
pubkey: ''
}
},
qrCodeDialog: {
data: {
payment_request: null
},
show: false
}
}
},
computed: {
filterProducts() {
if (!this.searchText || this.searchText.length < 2) return this.products
return this.products.filter(p => {
return (
p.product.includes(this.searchText) ||
p.description.includes(this.searchText) ||
p.categories.includes(this.searchText)
)
})
},
finalCost() {
if (!this.checkoutDialog.data.shippingzone) return this.cart.total
let zoneCost = this.stall.zones.find(
z => z.value == this.checkoutDialog.data.shippingzone
)
return +this.cart.total + zoneCost.cost
}
},
methods: {
closeQrCodeDialog() {
this.qrCodeDialog.dismissMsg()
this.qrCodeDialog.show = false
},
resetCart() {
this.cart = {
total: 0,
size: 0,
products: new Map()
}
},
getAmountFormated(amount) {
return LNbits.utils.formatCurrency(amount, this.unit)
},
async getRates() {
if (this.unit == 'sat') return
try {
let rate = (
await LNbits.api.request('POST', '/api/v1/conversion', null, {
amount: 1e8,
to: this.unit
})
).data
this.exchangeRate = rate[this.unit]
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getValueInSats(amount) {
if (!this.exchangeRate) return 0
return Math.ceil((amount / this.exchangeRate) * 1e8)
},
addToCart(item) {
let prod = this.cart.products
if (prod.has(item.id)) {
let qty = prod.get(item.id).quantity
prod.set(item.id, {
...prod.get(item.id),
quantity: qty + 1
})
} else {
prod.set(item.id, {
name: item.product,
quantity: 1,
price: item.price
})
}
this.$q.notify({
type: 'positive',
message: `${item.product} added to cart`,
icon: 'thumb_up'
})
this.cart.products = prod
this.updateCart(+item.price)
},
removeFromCart(item) {
this.cart.products.delete(item.id)
this.updateCart(+item.price, true)
},
updateCart(price, del = false) {
console.log(this.cart, this.cartMenu)
if (del) {
this.cart.total -= price
this.cart.size--
} else {
this.cart.total += price
this.cart.size++
}
this.cartMenu = Array.from(this.cart.products, item => {
return {id: item[0], ...item[1]}
})
console.log(this.cart, this.cartMenu)
},
getPubkey() {
let data = this.$q.localStorage.getItem(`lnbits.market.data`)
if (data && data.keys.publickey) {
this.checkoutDialog.data.pubkey = data.keys.publickey
} else {
this.$q.notify({
type: 'warning',
message: 'No public key stored!',
icon: 'settings_backup_restore'
})
}
},
placeOrder() {
let dialog = this.checkoutDialog.data
let data = {
...this.checkoutDialog.data,
wallet: this.stall.wallet,
total:
this.unit != 'sat'
? this.getValueInSats(this.finalCost)
: this.finalCost, // maybe this is better made in Python to allow API ordering?!
products: Array.from(this.cart.products, p => {
return {product_id: p[0], quantity: p[1].quantity}
})
}
LNbits.api
.request('POST', '/market/api/v1/orders', null, data)
.then(res => {
this.checkoutDialog = {show: false, data: {}}
return res.data
})
.then(data => {
this.qrCodeDialog.data = data
this.qrCodeDialog.show = true
this.qrCodeDialog.dismissMsg = this.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
return data
})
.then(data => {
this.qrCodeDialog.paymentChecker = setInterval(() => {
LNbits.api
.request(
'GET',
`/market/api/v1/orders/payments/${this.qrCodeDialog.data.payment_hash}`
)
.then(res => {
if (res.data.paid) {
this.$q.notify({
type: 'positive',
multiLine: true,
message:
"Sats received, thanks! You'l be redirected to the order page...",
icon: 'thumb_up',
actions: [
{
label: 'See Order',
handler: () => {
window.location.href = `/market/order/?merch=${this.stall.id}&invoice_id=${this.qrCodeDialog.data.payment_hash}`
}
}
]
})
clearInterval(this.qrCodeDialog.paymentChecker)
this.resetCart()
this.closeQrCodeDialog()
setTimeout(() => {
window.location.href = `/market/order/?merch=${this.stall.id}&invoice_id=${this.qrCodeDialog.data.payment_hash}`
}, 5000)
}
})
.catch(error => {
console.error(error)
LNbits.utils.notifyApiError(error)
})
}, 3000)
})
.catch(error => {
console.error(error)
LNbits.utils.notifyApiError(error)
})
}
},
async created() {
this.stall = JSON.parse('{{ stall | tojson }}')
this.products = JSON.parse('{{ products | tojson }}')
this.unit = this.stall.currency
if (this.unit != 'sat') {
this.products = this.products.map(mapProductsItems)
}
await this.getRates()
setInterval(this.getRates, 300000)
}
})
</script>
{% endblock %}

View File

@ -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)

View File

@ -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)

View File

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

View File

@ -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()

View File

@ -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."
),
)

View File

@ -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"""

View File

@ -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)

View File

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

View File

@ -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"]

View File

@ -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)

View File

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

View File

@ -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:

View File

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

View File

@ -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:

View File

@ -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):

View File

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

View File

@ -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)

View File

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

View File

@ -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",

View File

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

View File

@ -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)

View File

@ -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)

View File

@ -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."
)

View File

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

View File

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

View File

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

View File

@ -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.
)"""