bits bobs

This commit is contained in:
ben 2022-05-19 11:39:59 +01:00
parent b0db8d8f5a
commit 5e28183a24
7 changed files with 51 additions and 391 deletions

View file

@ -5,87 +5,62 @@ from . import db
from .models import ScrubLink, CreateScrubLinkData from .models import ScrubLink, CreateScrubLinkData
async def create_pay_link(data: CreateScrubLinkData, wallet_id: str) -> ScrubLink: async def create_scrub_link(wallet_id: str, data: CreateSatsDiceLink) -> satsdiceLink:
satsdice_id = urlsafe_short_hash()
returning = "" if db.type == SQLITE else "RETURNING ID" await db.execute(
method = db.execute if db.type == SQLITE else db.fetchone """
result = await (method)( INSERT INTO scrub.scrub_links (
f""" id,
INSERT INTO scrub.pay_links (
wallet, wallet,
description, description,
min, payoraddress,
max,
served_meta,
served_pr,
webhook_url,
success_text,
success_url,
comment_chars,
currency
) )
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?) VALUES (?, ?, ?)
{returning}
""", """,
( (
wallet_id, satsdice_id,
data.description, wallet,
data.min, description,
data.max, payoraddress,
data.webhook_url,
data.success_text,
data.success_url,
data.comment_chars,
data.currency,
), ),
) )
if db.type == SQLITE: link = await get_satsdice_pay(satsdice_id)
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" assert link, "Newly created link couldn't be retrieved"
return link return link
async def get_pay_link(link_id: int) -> Optional[ScrubLink]: async def get_scrub_link(link_id: str) -> Optional[satsdiceLink]:
row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,)) row = await db.fetchone(
return ScrubLink.from_row(row) if row else None "SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,)
)
return satsdiceLink(**row) if row else None
async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]: async def get_scrub_links(wallet_ids: Union[str, List[str]]) -> List[satsdiceLink]:
if isinstance(wallet_ids, str): if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids] wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids)) q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall( rows = await db.fetchall(
f""" f"""
SELECT * FROM scrub.pay_links WHERE wallet IN ({q}) SELECT * FROM scrub.scrub_links WHERE wallet IN ({q})
ORDER BY Id ORDER BY id
""", """,
(*wallet_ids,), (*wallet_ids,),
) )
return [ScrubLink.from_row(row) for row in rows] return [satsdiceLink(**row) for row in rows]
async def update_pay_link(link_id: int, **kwargs) -> Optional[ScrubLink]: async def update_scrub_link(link_id: int, **kwargs) -> Optional[satsdiceLink]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE scrub.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) f"UPDATE scrub.scrub_links SET {q} WHERE id = ?",
(*kwargs.values(), link_id),
) )
row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,)) row = await db.fetchone(
return ScrubLink.from_row(row) if row else None "SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,)
async def increment_pay_link(link_id: int, **kwargs) -> Optional[ScrubLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
await db.execute(
f"UPDATE scrub.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
) )
row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,)) return satsdiceLink(**row) if row else None
return ScrubLink.from_row(row) if row else None
async def delete_scrub_link(link_id: int) -> None:
async def delete_pay_link(link_id: int) -> None: await db.execute("DELETE FROM scrub.scrub_links WHERE id = ?", (link_id,))
await db.execute("DELETE FROM scrub.pay_links WHERE id = ?", (link_id,))

View file

@ -1,109 +0,0 @@
import hashlib
import math
from http import HTTPStatus
from fastapi import Request
from lnurl import ( # type: ignore
LnurlErrorResponse,
LnurlScrubActionResponse,
LnurlScrubResponse,
)
from starlette.exceptions import HTTPException
from lnbits.core.services import create_invoice
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
from . import scrub_ext
from .crud import increment_pay_link
@scrub_ext.get(
"/api/v1/lnurl/{link_id}",
status_code=HTTPStatus.OK,
name="scrub.api_lnurl_response",
)
async def api_lnurl_response(request: Request, link_id):
link = await increment_pay_link(link_id, served_meta=1)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Scrub link does not exist."
)
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
resp = LnurlScrubResponse(
callback=request.url_for("scrub.api_lnurl_callback", link_id=link.id),
min_sendable=math.ceil(link.min * rate) * 1000,
max_sendable=round(link.max * rate) * 1000,
metadata=link.scrubay_metadata,
)
params = resp.dict()
if link.comment_chars > 0:
params["commentAllowed"] = link.comment_chars
return params
@scrub_ext.get(
"/api/v1/lnurl/cb/{link_id}",
status_code=HTTPStatus.OK,
name="scrub.api_lnurl_callback",
)
async def api_lnurl_callback(request: Request, link_id):
link = await increment_pay_link(link_id, served_pr=1)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Scrub link does not exist."
)
min, max = link.min, link.max
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
if link.currency:
# allow some fluctuation (as the fiat price may have changed between the calls)
min = rate * 995 * link.min
max = rate * 1010 * link.max
else:
min = link.min * 1000
max = link.max * 1000
amount_received = int(request.query_params.get("amount") or 0)
if amount_received < min:
return LnurlErrorResponse(
reason=f"Amount {amount_received} is smaller than minimum {min}."
).dict()
elif amount_received > max:
return LnurlErrorResponse(
reason=f"Amount {amount_received} is greater than maximum {max}."
).dict()
comment = request.query_params.get("comment")
if len(comment or "") > link.comment_chars:
return LnurlErrorResponse(
reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
).dict()
payment_hash, payment_request = await create_invoice(
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo=link.description,
description_hash=hashlib.sha256(
link.scrubay_metadata.encode("utf-8")
).digest(),
extra={
"tag": "scrub",
"link": link.id,
"comment": comment,
"extra": request.query_params.get("amount"),
},
)
success_action = link.success_action(payment_hash)
if success_action:
resp = LnurlScrubActionResponse(
pr=payment_request, success_action=success_action, routes=[]
)
else:
resp = LnurlScrubActionResponse(pr=payment_request, routes=[])
return resp.dict()

View file

@ -1,51 +1,14 @@
async def m001_initial(db): async def m001_initial(db):
""" """
Initial pay table. Initial scrub table.
""" """
await db.execute( await db.execute(
f""" f"""
CREATE TABLE scrub.pay_links ( CREATE TABLE scrub.scrub_links (
id {db.serial_primary_key}, id TEXT PRIMARY KEY,
wallet TEXT NOT NULL, wallet TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
webhook INTEGER NOT NULL, payoraddress TEXT NOT NULL
payoraddress INTEGER NOT NULL
); );
""" """
) )
async def m002_webhooks_and_success_actions(db):
"""
Webhooks and success actions.
"""
await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN webhook_url TEXT;")
await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN success_text TEXT;")
await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN success_url TEXT;")
await db.execute(
f"""
CREATE TABLE scrub.invoices (
pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id),
payment_hash TEXT NOT NULL,
webhook_sent INT, -- null means not sent, otherwise store status
expiry INT
);
"""
)
async def m003_min_max_comment_fiat(db):
"""
Support for min/max amounts, comments and fiat prices that get
converted automatically to satoshis based on some API.
"""
await db.execute(
"ALTER TABLE scrub.pay_links ADD COLUMN currency TEXT;"
) # null = satoshis
await db.execute(
"ALTER TABLE scrub.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;"
)
await db.execute("ALTER TABLE scrub.pay_links RENAME COLUMN amount TO min;")
await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN max INTEGER;")
await db.execute("UPDATE scrub.pay_links SET max = min;")
await db.execute("DROP TABLE scrub.invoices")

View file

@ -8,31 +8,11 @@ from lnurl.types import LnurlScrubMetadata # type: ignore
from sqlite3 import Row from sqlite3 import Row
from pydantic import BaseModel from pydantic import BaseModel
class CreateScrubLinkData(BaseModel):
description: str
min: int = Query(0.01, ge=0.01)
max: int = Query(0.01, ge=0.01)
currency: str = Query(None)
comment_chars: int = Query(0, ge=0, lt=800)
webhook_url: str = Query(None)
success_text: str = Query(None)
success_url: str = Query(None)
class ScrubLink(BaseModel): class ScrubLink(BaseModel):
id: int id: int
wallet: str wallet: str
description: str description: str
min: int payoraddress: str
served_meta: int
served_pr: int
webhook_url: Optional[str]
success_text: Optional[str]
success_url: Optional[str]
currency: Optional[str]
comment_chars: int
max: int
@classmethod @classmethod
def from_row(cls, row: Row) -> "ScrubLink": def from_row(cls, row: Row) -> "ScrubLink":
@ -46,19 +26,3 @@ class ScrubLink(BaseModel):
@property @property
def scrubay_metadata(self) -> LnurlScrubMetadata: def scrubay_metadata(self) -> LnurlScrubMetadata:
return LnurlScrubMetadata(json.dumps([["text/plain", self.description]])) return LnurlScrubMetadata(json.dumps([["text/plain", self.description]]))
def success_action(self, payment_hash: str) -> Optional[Dict]:
if self.success_url:
url: ParseResult = urlparse(self.success_url)
qs: Dict = parse_qs(url.query)
qs["payment_hash"] = payment_hash
url = url._replace(query=urlencode(qs, doseq=True))
return {
"tag": "url",
"description": self.success_text or "~",
"url": urlunparse(url),
}
elif self.success_text:
return {"tag": "message", "message": self.success_text}
else:
return None

View file

@ -28,23 +28,7 @@ async def on_invoice_paid(payment: Scrubment) -> None:
return return
pay_link = await get_pay_link(payment.extra.get("link", -1)) pay_link = await get_pay_link(payment.extra.get("link", -1))
if pay_link and pay_link.webhook_url: # PAY LNURLP AND LNADDRESS
async with httpx.AsyncClient() as client:
try:
r = await client.post(
pay_link.webhook_url,
json={
"payment_hash": payment.payment_hash,
"payment_request": payment.bolt11,
"amount": payment.amount,
"comment": payment.extra.get("comment"),
"scrub": pay_link.id,
},
timeout=40,
)
await mark_webhook_sent(payment, r.status_code)
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)
async def mark_webhook_sent(payment: Scrubment, status: int) -> None: async def mark_webhook_sent(payment: Scrubment, status: int) -> None:

View file

@ -154,76 +154,13 @@
type="text" type="text"
label="Item description *" label="Item description *"
></q-input> ></q-input>
<div class="row q-col-gutter-sm">
<q-input <q-input
filled filled
dense dense
v-model.number="formDialog.data.min" v-model.trim="formDialog.data.payoraddress"
type="number"
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
:label="formDialog.fixedAmount ? 'Amount *' : 'Min *'"
></q-input>
<q-input
v-if="!formDialog.fixedAmount"
filled
dense
v-model.number="formDialog.data.max"
type="number"
:step="formDialog.data.currency && formDialog.data.currency !== 'satoshis' ? '0.01' : '1'"
label="Max *"
></q-input>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-checkbox
dense
v-model="formDialog.fixedAmount"
label="Fixed amount"
/>
</div>
<div class="col">
<q-select
dense
:options="currencies"
v-model="formDialog.data.currency"
:display-value="formDialog.data.currency || 'satoshis'"
label="Currency"
:hint="'Amounts will be converted at use-time to satoshis. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
@input="updateFiatRate"
/>
</div>
</div>
<q-input
filled
dense
v-model.number="formDialog.data.comment_chars"
type="number"
label="Comment maximum characters"
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. scrub will store the comment and send it in the webhook."
></q-input>
<q-input
filled
dense
v-model="formDialog.data.webhook_url"
type="text" type="text"
label="Webhook URL (optional)" label="LNURLPay or LNAdress *"
hint="A URL to be called whenever this link receives a payment."
></q-input>
<q-input
filled
dense
v-model="formDialog.data.success_text"
type="text"
label="Success message (optional)"
hint="Will be shown to the user in his wallet after a successful payment."
></q-input>
<q-input
filled
dense
v-model="formDialog.data.success_url"
type="text"
label="Success URL (optional)"
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string."
></q-input> ></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
@ -240,10 +177,7 @@
:disable=" :disable="
formDialog.data.wallet == null || formDialog.data.wallet == null ||
formDialog.data.description == null || formDialog.data.description == null ||
( formDialog.data.payoraddress == null
formDialog.data.min == null ||
formDialog.data.min <= 0
)
" "
type="submit" type="submit"
>Create pay link</q-btn >Create pay link</q-btn
@ -255,57 +189,6 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
<span v-if="qrCodeDialog.data.currency"
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{
fiatRates[qrCodeDialog.data.currency] ?
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br
/></span>
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br />
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
}}<br />
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br />
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-ml-sm"
>Copy LNURL</q-btn
>
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
>Shareable link</q-btn
>
<q-btn
outline
color="grey"
icon="print"
type="a"
:href="qrCodeDialog.data.print_url"
target="_blank"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="/scrub/static/js/index.js"></script> <script src="/scrub/static/js/index.js"></script>

View file

@ -12,11 +12,11 @@ from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
from . import scrub_ext from . import scrub_ext
from .crud import ( from .crud import (
create_pay_link, create_scrub_link,
delete_pay_link, delete_scrub_link,
get_pay_link, get_scrub_link,
get_pay_links, get_scrub_links,
update_pay_link, update_scrub_link,
) )
from .models import CreateScrubLinkData from .models import CreateScrubLinkData
@ -39,14 +39,14 @@ async def api_links(
try: try:
return [ return [
{**link.dict(), "lnurl": link.lnurl(req)} {**link.dict()}
for link in await get_pay_links(wallet_ids) for link in await get_pay_links(wallet_ids)
] ]
except LnurlInvalidUrl: except:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED, status_code=HTTPStatus.NOT_FOUND,
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.", detail="No links available",
) )