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
async def create_pay_link(data: CreateScrubLinkData, wallet_id: str) -> ScrubLink:
returning = "" if db.type == SQLITE else "RETURNING ID"
method = db.execute if db.type == SQLITE else db.fetchone
result = await (method)(
f"""
INSERT INTO scrub.pay_links (
async def create_scrub_link(wallet_id: str, data: CreateSatsDiceLink) -> satsdiceLink:
satsdice_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO scrub.scrub_links (
id,
wallet,
description,
min,
max,
served_meta,
served_pr,
webhook_url,
success_text,
success_url,
comment_chars,
currency
payoraddress,
)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
{returning}
VALUES (?, ?, ?)
""",
(
wallet_id,
data.description,
data.min,
data.max,
data.webhook_url,
data.success_text,
data.success_url,
data.comment_chars,
data.currency,
satsdice_id,
wallet,
description,
payoraddress,
),
)
if db.type == SQLITE:
link_id = result._result_proxy.lastrowid
else:
link_id = result[0]
link = await get_pay_link(link_id)
link = await get_satsdice_pay(satsdice_id)
assert link, "Newly created link couldn't be retrieved"
return link
async def get_pay_link(link_id: int) -> Optional[ScrubLink]:
row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,))
return ScrubLink.from_row(row) if row else None
async def get_scrub_link(link_id: str) -> Optional[satsdiceLink]:
row = await db.fetchone(
"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):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"""
SELECT * FROM scrub.pay_links WHERE wallet IN ({q})
ORDER BY Id
SELECT * FROM scrub.scrub_links WHERE wallet IN ({q})
ORDER BY id
""",
(*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()])
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,))
return ScrubLink.from_row(row) if row else None
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.scrub_links WHERE id = ?", (link_id,)
)
row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,))
return ScrubLink.from_row(row) if row else None
return satsdiceLink(**row) if row else None
async def delete_pay_link(link_id: int) -> None:
await db.execute("DELETE FROM scrub.pay_links WHERE id = ?", (link_id,))
async def delete_scrub_link(link_id: int) -> None:
await db.execute("DELETE FROM scrub.scrub_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):
"""
Initial pay table.
Initial scrub table.
"""
await db.execute(
f"""
CREATE TABLE scrub.pay_links (
id {db.serial_primary_key},
CREATE TABLE scrub.scrub_links (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
description TEXT NOT NULL,
webhook INTEGER NOT NULL,
payoraddress INTEGER NOT NULL
payoraddress TEXT 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 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):
id: int
wallet: str
description: str
min: int
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
payoraddress: str
@classmethod
def from_row(cls, row: Row) -> "ScrubLink":
@ -45,20 +25,4 @@ class ScrubLink(BaseModel):
@property
def scrubay_metadata(self) -> LnurlScrubMetadata:
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
return LnurlScrubMetadata(json.dumps([["text/plain", self.description]]))

View file

@ -28,23 +28,7 @@ async def on_invoice_paid(payment: Scrubment) -> None:
return
pay_link = await get_pay_link(payment.extra.get("link", -1))
if pay_link and pay_link.webhook_url:
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)
# PAY LNURLP AND LNADDRESS
async def mark_webhook_sent(payment: Scrubment, status: int) -> None:

View file

@ -154,76 +154,13 @@
type="text"
label="Item description *"
></q-input>
<div class="row q-col-gutter-sm">
<q-input
filled
dense
v-model.number="formDialog.data.min"
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"
v-model.trim="formDialog.data.payoraddress"
type="text"
label="Webhook URL (optional)"
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."
label="LNURLPay or LNAdress *"
></q-input>
<div class="row q-mt-lg">
<q-btn
@ -240,10 +177,7 @@
:disable="
formDialog.data.wallet == null ||
formDialog.data.description == null ||
(
formDialog.data.min == null ||
formDialog.data.min <= 0
)
formDialog.data.payoraddress == null
"
type="submit"
>Create pay link</q-btn
@ -255,57 +189,6 @@
</q-form>
</q-card>
</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>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<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 .crud import (
create_pay_link,
delete_pay_link,
get_pay_link,
get_pay_links,
update_pay_link,
create_scrub_link,
delete_scrub_link,
get_scrub_link,
get_scrub_links,
update_scrub_link,
)
from .models import CreateScrubLinkData
@ -39,14 +39,14 @@ async def api_links(
try:
return [
{**link.dict(), "lnurl": link.lnurl(req)}
{**link.dict()}
for link in await get_pay_links(wallet_ids)
]
except LnurlInvalidUrl:
except:
raise HTTPException(
status_code=HTTPStatus.UPGRADE_REQUIRED,
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
status_code=HTTPStatus.NOT_FOUND,
detail="No links available",
)