mirror of
https://github.com/lnbits/lnbits.git
synced 2025-07-07 14:00:38 +02:00
init
This commit is contained in:
27
lnbits/extensions/scrub/README.md
Normal file
27
lnbits/extensions/scrub/README.md
Normal file
@ -0,0 +1,27 @@
|
||||
# scrub
|
||||
|
||||
## Create a static QR code people can use to pay over Lightning Network
|
||||
|
||||
LNURL is a range of lightning-network standards that allow us to use lightning-network differently. An LNURL-pay is a link that wallets use to fetch an invoice from a server on-demand. The link or QR code is fixed, but each time it is read by a compatible wallet a new invoice is issued by the service and sent to the wallet.
|
||||
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create an scrub (New Scrub link)\
|
||||

|
||||
|
||||
- select your wallets
|
||||
- make a small description
|
||||
- enter amount
|
||||
- if _Fixed amount_ is unchecked you'll have the option to configure a Max and Min amount
|
||||
- you can set the currency to something different than sats. For example if you choose EUR, the satoshi amount will be calculated when a user scans the scrub
|
||||
- You can ask the user to send a comment that will be sent along with the payment (for example a comment to a blog post)
|
||||
- Webhook URL allows to call an URL when the scrub is paid
|
||||
- Success mesage, will send a message back to the user after a successful payment, for example a thank you note
|
||||
- Success URL, will send back a clickable link to the user. Access to some hidden content, or a download link
|
||||
|
||||
2. Use the shareable link or view the scrub you just created\
|
||||

|
||||
- you can now open your scrub and copy the LNURL, get the shareable link or print it\
|
||||

|
35
lnbits/extensions/scrub/__init__.py
Normal file
35
lnbits/extensions/scrub/__init__.py
Normal file
@ -0,0 +1,35 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
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_scrub")
|
||||
|
||||
scrub_static_files = [
|
||||
{
|
||||
"path": "/scrub/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/scrub/static"),
|
||||
"name": "scrub_static",
|
||||
}
|
||||
]
|
||||
|
||||
scrub_ext: APIRouter = APIRouter(prefix="/scrub", tags=["scrub"])
|
||||
|
||||
|
||||
def scrub_renderer():
|
||||
return template_renderer(["lnbits/extensions/scrub/templates"])
|
||||
|
||||
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def scrub_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
8
lnbits/extensions/scrub/config.json
Normal file
8
lnbits/extensions/scrub/config.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "scrub",
|
||||
"short_description": "Pass payments to LNURLp/LNaddress",
|
||||
"icon": "send",
|
||||
"contributors": [
|
||||
"arcbtc"
|
||||
]
|
||||
}
|
91
lnbits/extensions/scrub/crud.py
Normal file
91
lnbits/extensions/scrub/crud.py
Normal file
@ -0,0 +1,91 @@
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.db import SQLITE
|
||||
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 (
|
||||
wallet,
|
||||
description,
|
||||
min,
|
||||
max,
|
||||
served_meta,
|
||||
served_pr,
|
||||
webhook_url,
|
||||
success_text,
|
||||
success_url,
|
||||
comment_chars,
|
||||
currency
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
|
||||
{returning}
|
||||
""",
|
||||
(
|
||||
wallet_id,
|
||||
data.description,
|
||||
data.min,
|
||||
data.max,
|
||||
data.webhook_url,
|
||||
data.success_text,
|
||||
data.success_url,
|
||||
data.comment_chars,
|
||||
data.currency,
|
||||
),
|
||||
)
|
||||
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[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_pay_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]:
|
||||
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
|
||||
""",
|
||||
(*wallet_ids,),
|
||||
)
|
||||
return [ScrubLink.from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def update_pay_link(link_id: int, **kwargs) -> Optional[ScrubLink]:
|
||||
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)
|
||||
)
|
||||
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.pay_links WHERE id = ?", (link_id,))
|
||||
return ScrubLink.from_row(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,))
|
109
lnbits/extensions/scrub/lnurl.py
Normal file
109
lnbits/extensions/scrub/lnurl.py
Normal file
@ -0,0 +1,109 @@
|
||||
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()
|
51
lnbits/extensions/scrub/migrations.py
Normal file
51
lnbits/extensions/scrub/migrations.py
Normal file
@ -0,0 +1,51 @@
|
||||
async def m001_initial(db):
|
||||
"""
|
||||
Initial pay table.
|
||||
"""
|
||||
await db.execute(
|
||||
f"""
|
||||
CREATE TABLE scrub.pay_links (
|
||||
id {db.serial_primary_key},
|
||||
wallet TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
webhook INTEGER 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")
|
64
lnbits/extensions/scrub/models.py
Normal file
64
lnbits/extensions/scrub/models.py
Normal file
@ -0,0 +1,64 @@
|
||||
import json
|
||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
|
||||
from starlette.requests import Request
|
||||
from fastapi.param_functions import Query
|
||||
from typing import Optional, Dict
|
||||
from lnbits.lnurl import encode as lnurl_encode # type: ignore
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "ScrubLink":
|
||||
data = dict(row)
|
||||
return cls(**data)
|
||||
|
||||
def lnurl(self, req: Request) -> str:
|
||||
url = req.url_for("scrub.api_lnurl_response", link_id=self.id)
|
||||
return lnurl_encode(url)
|
||||
|
||||
@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
|
227
lnbits/extensions/scrub/static/js/index.js
Normal file
227
lnbits/extensions/scrub/static/js/index.js
Normal file
@ -0,0 +1,227 @@
|
||||
/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
|
||||
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
var locationPath = [
|
||||
window.location.protocol,
|
||||
'//',
|
||||
window.location.host,
|
||||
window.location.pathname
|
||||
].join('')
|
||||
|
||||
var mapScrubLink = obj => {
|
||||
obj._data = _.clone(obj)
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.print_url = [locationPath, 'print/', obj.id].join('')
|
||||
obj.pay_url = [locationPath, obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data() {
|
||||
return {
|
||||
currencies: [],
|
||||
fiatRates: {},
|
||||
checker: null,
|
||||
payLinks: [],
|
||||
payLinksTable: {
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
fixedAmount: true,
|
||||
data: {}
|
||||
},
|
||||
qrCodeDialog: {
|
||||
show: false,
|
||||
data: null
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getScrubLinks() {
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/scrub/api/v1/links?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = response.data.map(mapScrubLink)
|
||||
})
|
||||
.catch(err => {
|
||||
clearInterval(this.checker)
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
closeFormDialog() {
|
||||
this.resetFormData()
|
||||
},
|
||||
openQrCodeDialog(linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
||||
this.qrCodeDialog.data = {
|
||||
id: link.id,
|
||||
amount:
|
||||
(link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
|
||||
' ' +
|
||||
(link.currency || 'sat'),
|
||||
currency: link.currency,
|
||||
comments: link.comment_chars
|
||||
? `${link.comment_chars} characters`
|
||||
: 'no',
|
||||
webhook: link.webhook_url || 'nowhere',
|
||||
success:
|
||||
link.success_text || link.success_url
|
||||
? 'Display message "' +
|
||||
link.success_text +
|
||||
'"' +
|
||||
(link.success_url ? ' and URL "' + link.success_url + '"' : '')
|
||||
: 'do nothing',
|
||||
lnurl: link.lnurl,
|
||||
pay_url: link.pay_url,
|
||||
print_url: link.print_url
|
||||
}
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
openUpdateDialog(linkId) {
|
||||
const link = _.findWhere(this.payLinks, {id: linkId})
|
||||
if (link.currency) this.updateFiatRate(link.currency)
|
||||
|
||||
this.formDialog.data = _.clone(link._data)
|
||||
this.formDialog.show = true
|
||||
this.formDialog.fixedAmount =
|
||||
this.formDialog.data.min === this.formDialog.data.max
|
||||
},
|
||||
sendFormData() {
|
||||
const wallet = _.findWhere(this.g.user.wallets, {
|
||||
id: this.formDialog.data.wallet
|
||||
})
|
||||
var data = _.omit(this.formDialog.data, 'wallet')
|
||||
|
||||
if (this.formDialog.fixedAmount) data.max = data.min
|
||||
if (data.currency === 'satoshis') data.currency = null
|
||||
if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
|
||||
|
||||
if (data.id) {
|
||||
this.updateScrubLink(wallet, data)
|
||||
} else {
|
||||
this.createScrubLink(wallet, data)
|
||||
}
|
||||
},
|
||||
resetFormData() {
|
||||
this.formDialog = {
|
||||
show: false,
|
||||
fixedAmount: true,
|
||||
data: {}
|
||||
}
|
||||
},
|
||||
updateScrubLink(wallet, data) {
|
||||
let values = _.omit(
|
||||
_.pick(
|
||||
data,
|
||||
'description',
|
||||
'min',
|
||||
'max',
|
||||
'webhook_url',
|
||||
'success_text',
|
||||
'success_url',
|
||||
'comment_chars',
|
||||
'currency'
|
||||
),
|
||||
(value, key) =>
|
||||
(key === 'webhook_url' ||
|
||||
key === 'success_text' ||
|
||||
key === 'success_url') &&
|
||||
(value === null || value === '')
|
||||
)
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
'/scrub/api/v1/links/' + data.id,
|
||||
wallet.adminkey,
|
||||
values
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
|
||||
this.payLinks.push(mapScrubLink(response.data))
|
||||
this.formDialog.show = false
|
||||
this.resetFormData()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
createScrubLink(wallet, data) {
|
||||
LNbits.api
|
||||
.request('POST', '/scrub/api/v1/links', wallet.adminkey, data)
|
||||
.then(response => {
|
||||
this.getScrubLinks()
|
||||
this.formDialog.show = false
|
||||
this.resetFormData()
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
deleteScrubLink(linkId) {
|
||||
var link = _.findWhere(this.payLinks, {id: linkId})
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this pay link?')
|
||||
.onOk(() => {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/scrub/api/v1/links/' + linkId,
|
||||
_.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
|
||||
)
|
||||
.then(response => {
|
||||
this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
updateFiatRate(currency) {
|
||||
LNbits.api
|
||||
.request('GET', '/scrub/api/v1/rate/' + currency, null)
|
||||
.then(response => {
|
||||
let rates = _.clone(this.fiatRates)
|
||||
rates[currency] = response.data.rate
|
||||
this.fiatRates = rates
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.g.user.wallets.length) {
|
||||
var getScrubLinks = this.getScrubLinks
|
||||
getScrubLinks()
|
||||
this.checker = setInterval(() => {
|
||||
getScrubLinks()
|
||||
}, 20000)
|
||||
}
|
||||
LNbits.api
|
||||
.request('GET', '/scrub/api/v1/currencies')
|
||||
.then(response => {
|
||||
this.currencies = ['satoshis', ...response.data]
|
||||
})
|
||||
.catch(err => {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
}
|
||||
})
|
59
lnbits/extensions/scrub/tasks.py
Normal file
59
lnbits/extensions/scrub/tasks.py
Normal file
@ -0,0 +1,59 @@
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Scrubment
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_pay_link
|
||||
|
||||
|
||||
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: Scrubment) -> None:
|
||||
if "scrub" != payment.extra.get("tag"):
|
||||
# not an scrub invoice
|
||||
return
|
||||
|
||||
if payment.extra.get("wh_status"):
|
||||
# this webhook has already been sent
|
||||
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)
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Scrubment, status: int) -> None:
|
||||
payment.extra["wh_status"] = status
|
||||
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(json.dumps(payment.extra), payment.payment_hash),
|
||||
)
|
135
lnbits/extensions/scrub/templates/lnurlp/_api_docs.html
Normal file
135
lnbits/extensions/scrub/templates/lnurlp/_api_docs.html
Normal file
@ -0,0 +1,135 @@
|
||||
<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="List pay links">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span> /scrub/api/v1/links</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</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>[<pay_link_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}api/v1/links -H "X-Api-Key: {{
|
||||
user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Get a pay link">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-blue">GET</span>
|
||||
/scrub/api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</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 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>{"lnurl": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}api/v1/links/<pay_id> -H
|
||||
"X-Api-Key: {{ user.wallets[0].inkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Create a pay link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-green">POST</span> /scrub/api/v1/links</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"description": <string> "amount": <integer> "max":
|
||||
<integer> "min": <integer> "comment_chars":
|
||||
<integer>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code>{"lnurl": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}api/v1/links -d '{"description":
|
||||
<string>, "amount": <integer>, "max": <integer>,
|
||||
"min": <integer>, "comment_chars": <integer>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Update a pay link"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-green">PUT</span>
|
||||
/scrub/api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code>{"description": <string>, "amount": <integer>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>{"lnurl": <string>}</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X PUT {{ request.base_url }}api/v1/links/<pay_id> -d
|
||||
'{"description": <string>, "amount": <integer>}' -H
|
||||
"Content-type: application/json" -H "X-Api-Key: {{
|
||||
user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a pay link"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/scrub/api/v1/links/<pay_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</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 }}api/v1/links/<pay_id> -H
|
||||
"X-Api-Key: {{ user.wallets[0].adminkey }}"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
</q-expansion-item>
|
28
lnbits/extensions/scrub/templates/lnurlp/_lnurl.html
Normal file
28
lnbits/extensions/scrub/templates/lnurlp/_lnurl.html
Normal file
@ -0,0 +1,28 @@
|
||||
<q-expansion-item group="extras" icon="info" label="Powered by LNURL">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
<b>WARNING: LNURL must be used over https or TOR</b><br />
|
||||
LNURL is a range of lightning-network standards that allow us to use
|
||||
lightning-network differently. An LNURL-pay is a link that wallets use
|
||||
to fetch an invoice from a server on-demand. The link or QR code is
|
||||
fixed, but each time it is read by a compatible wallet a new QR code is
|
||||
issued by the service. It can be used to activate machines without them
|
||||
having to maintain an electronic screen to generate and show invoices
|
||||
locally, or to sell any predefined good or service automatically.
|
||||
</p>
|
||||
<p>
|
||||
Exploring LNURL and finding use cases, is really helping inform
|
||||
lightning protocol development, rather than the protocol dictating how
|
||||
lightning-network should be engaged with.
|
||||
</p>
|
||||
<small
|
||||
>Check
|
||||
<a href="https://github.com/fiatjaf/awesome-lnurl" target="_blank"
|
||||
>Awesome LNURL</a
|
||||
>
|
||||
for further information.</small
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
47
lnbits/extensions/scrub/templates/lnurlp/display.html
Normal file
47
lnbits/extensions/scrub/templates/lnurlp/display.html
Normal file
@ -0,0 +1,47 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
|
||||
<q-card class="q-pa-lg">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div class="text-center">
|
||||
<a href="lightning:{{ lnurl }}">
|
||||
<q-responsive :ratio="1" class="q-mx-md">
|
||||
<qrcode
|
||||
value="{{ lnurl }}"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
</a>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn outline color="grey" @click="copyText('{{ lnurl }}')"
|
||||
>Copy LNURL</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-md-5 col-lg-4 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mb-sm q-mt-none">LNbits LNURL-pay link</h6>
|
||||
<p class="q-my-none">Use an LNURL compatible bitcoin wallet to pay.</p>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list> {% include "scrub/_lnurl.html" %} </q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin]
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
312
lnbits/extensions/scrub/templates/lnurlp/index.html
Normal file
312
lnbits/extensions/scrub/templates/lnurlp/index.html
Normal file
@ -0,0 +1,312 @@
|
||||
{% 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-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<q-btn unelevated color="primary" @click="formDialog.show = true"
|
||||
>New pay link</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">Scrub links</h5>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="payLinks"
|
||||
row-key="id"
|
||||
:pagination.sync="payLinksTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th auto-width>Description</q-th>
|
||||
<q-th auto-width>Amount</q-th>
|
||||
<q-th auto-width>Currency</q-th>
|
||||
<q-th auto-width></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="launch"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="props.row.pay_url"
|
||||
target="_blank"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="visibility"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
<q-td auto-width>{{ props.row.description }}</q-td>
|
||||
<q-td auto-width>
|
||||
<span v-if="props.row.min == props.row.max">
|
||||
{{ props.row.min }}
|
||||
</span>
|
||||
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
|
||||
</q-td>
|
||||
<q-td>{{ props.row.currency || 'sat' }}</q-td>
|
||||
<q-td>
|
||||
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
|
||||
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon
|
||||
v-if="props.row.success_text || props.row.success_url"
|
||||
size="14px"
|
||||
name="call_to_action"
|
||||
>
|
||||
<q-tooltip>
|
||||
On success, show message '{{ props.row.success_text }}'
|
||||
<span v-if="props.row.success_url"
|
||||
>and URL '{{ props.row.success_url }}'</span
|
||||
>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon
|
||||
v-if="props.row.comment_chars > 0"
|
||||
size="14px"
|
||||
name="insert_comment"
|
||||
>
|
||||
<q-tooltip>
|
||||
{{ props.row.comment_chars }}-char comment allowed
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="openUpdateDialog(props.row.id)"
|
||||
icon="edit"
|
||||
color="light-blue"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteScrubLink(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}} LNURL-pay extension
|
||||
</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "scrub/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
{% include "scrub/_lnurl.html" %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
|
||||
<q-form @submit="sendFormData" class="q-gutter-md">
|
||||
<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.description"
|
||||
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"
|
||||
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."
|
||||
></q-input>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialog.data.id"
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
>Update pay link</q-btn
|
||||
>
|
||||
<q-btn
|
||||
v-else
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="
|
||||
formDialog.data.wallet == null ||
|
||||
formDialog.data.description == null ||
|
||||
(
|
||||
formDialog.data.min == null ||
|
||||
formDialog.data.min <= 0
|
||||
)
|
||||
"
|
||||
type="submit"
|
||||
>Create pay link</q-btn
|
||||
>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</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>
|
||||
{% endblock %}
|
27
lnbits/extensions/scrub/templates/lnurlp/print_qr.html
Normal file
27
lnbits/extensions/scrub/templates/lnurlp/print_qr.html
Normal file
@ -0,0 +1,27 @@
|
||||
{% extends "print.html" %} {% block page %}
|
||||
<div class="row justify-center">
|
||||
<div class="qr">
|
||||
<qrcode value="{{ lnurl }}" :options="{width}"></qrcode>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block styles %}
|
||||
<style>
|
||||
.qr {
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
created: function () {
|
||||
window.print()
|
||||
},
|
||||
data: function () {
|
||||
return {width: window.innerWidth * 0.5}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
44
lnbits/extensions/scrub/views.py
Normal file
44
lnbits/extensions/scrub/views.py
Normal file
@ -0,0 +1,44 @@
|
||||
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 scrub_ext, scrub_renderer
|
||||
from .crud import get_pay_link
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@scrub_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
return scrub_renderer().TemplateResponse(
|
||||
"scrub/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@scrub_ext.get("/{link_id}", response_class=HTMLResponse)
|
||||
async def display(request: Request, link_id):
|
||||
link = await get_pay_link(link_id)
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Scrub link does not exist."
|
||||
)
|
||||
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
|
||||
return scrub_renderer().TemplateResponse("scrub/display.html", ctx)
|
||||
|
||||
|
||||
@scrub_ext.get("/print/{link_id}", response_class=HTMLResponse)
|
||||
async def print_qr(request: Request, link_id):
|
||||
link = await get_pay_link(link_id)
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Scrub link does not exist."
|
||||
)
|
||||
ctx = {"request": request, "lnurl": link.lnurl(req=request)}
|
||||
return scrub_renderer().TemplateResponse("scrub/print_qr.html", ctx)
|
146
lnbits/extensions/scrub/views_api.py
Normal file
146
lnbits/extensions/scrub/views_api.py
Normal file
@ -0,0 +1,146 @@
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
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 lnbits.core.crud import get_user
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type
|
||||
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,
|
||||
)
|
||||
from .models import CreateScrubLinkData
|
||||
|
||||
|
||||
@scrub_ext.get("/api/v1/currencies")
|
||||
async def api_list_currencies_available():
|
||||
return list(currencies.keys())
|
||||
|
||||
|
||||
@scrub_ext.get("/api/v1/links", status_code=HTTPStatus.OK)
|
||||
async def api_links(
|
||||
req: Request,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
all_wallets: bool = Query(False),
|
||||
):
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
|
||||
if all_wallets:
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
|
||||
try:
|
||||
return [
|
||||
{**link.dict(), "lnurl": link.lnurl(req)}
|
||||
for link in await get_pay_links(wallet_ids)
|
||||
]
|
||||
|
||||
except LnurlInvalidUrl:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.UPGRADE_REQUIRED,
|
||||
detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
|
||||
)
|
||||
|
||||
|
||||
@scrub_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_retrieve(
|
||||
r: Request, link_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
link = await get_pay_link(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if link.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
return {**link.dict(), **{"lnurl": link.lnurl(r)}}
|
||||
|
||||
|
||||
@scrub_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
|
||||
@scrub_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
|
||||
async def api_link_create_or_update(
|
||||
data: CreateScrubLinkData,
|
||||
link_id=None,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
):
|
||||
if data.min < 1:
|
||||
raise HTTPException(
|
||||
detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if data.min > data.max:
|
||||
raise HTTPException(
|
||||
detail="Min is greater than max.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if data.currency == None and (
|
||||
round(data.min) != data.min or round(data.max) != data.max
|
||||
):
|
||||
raise HTTPException(
|
||||
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if "success_url" in data and data.success_url[:8] != "https://":
|
||||
raise HTTPException(
|
||||
detail="Success URL must be secure https://...",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if link_id:
|
||||
link = await get_pay_link(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if link.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
link = await update_pay_link(**data.dict(), link_id=link_id)
|
||||
else:
|
||||
link = await create_pay_link(data, wallet_id=wallet.wallet.id)
|
||||
return {**link.dict(), "lnurl": link.lnurl}
|
||||
|
||||
|
||||
@scrub_ext.delete("/api/v1/links/{link_id}")
|
||||
async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
link = await get_pay_link(link_id)
|
||||
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
detail="Scrub link does not exist.", status_code=HTTPStatus.NOT_FOUND
|
||||
)
|
||||
|
||||
if link.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
|
||||
)
|
||||
|
||||
await delete_pay_link(link_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@scrub_ext.get("/api/v1/rate/{currency}", status_code=HTTPStatus.OK)
|
||||
async def api_check_fiat_rate(currency):
|
||||
try:
|
||||
rate = await get_fiat_rate_satoshis(currency)
|
||||
except AssertionError:
|
||||
rate = None
|
||||
|
||||
return {"rate": rate}
|
Reference in New Issue
Block a user