bits bobs
This commit is contained in:
parent
b0db8d8f5a
commit
5e28183a24
7 changed files with 51 additions and 391 deletions
|
|
@ -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,))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
@ -46,19 +26,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue