GET /api/v1/wallet
{"X-Api-Key": "{{ wallet.adminkey }}"}{"X-Api-Key": "{{ wallet.inkey }}"}curl {{ request.url_root }}api/v1/wallet -H "X-Api-Key:
+ >curl {{ request.base_url }}api/v1/wallet -H "X-Api-Key:
{{ wallet.inkey }}"
curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": false,
+ >curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": false,
"amount": <int>, "memo": <string>, "webhook":
- <url:string>}' -H "X-Api-Key: {{ wallet.inkey }}" -H
+ <url:string>, "unit": <string>}' -H "X-Api-Key: {{ wallet.inkey }}" -H
"Content-type: application/json"
@@ -86,7 +86,7 @@
{"payment_hash": <string>}
curl -X POST {{ request.url_root }}api/v1/payments -d '{"out": true,
+ >curl -X POST {{ request.base_url }}api/v1/payments -d '{"out": true,
"bolt11": <string>}' -H "X-Api-Key:
{{ wallet.adminkey }}" -H "Content-type:
application/json"
+
+ POST
+ /api/v1/payments/decode
+ {"X-Api-Key": "{{ wallet.inkey }}"}{"invoice": <string>}
+ curl -X POST {{ request.base_url }}api/v1/payments/decode -d
+ '{"data": <bolt11/lnurl, string>}' -H "X-Api-Key:
+ {{ wallet.inkey }}" -H "Content-type: application/json"
+ curl -X GET {{ request.url_root
+ >curl -X GET {{ request.base_url
}}api/v1/payments/<payment_hash> -H "X-Api-Key:
{{ wallet.inkey }}" -H "Content-type: application/json"
diff --git a/lnbits/core/templates/core/extensions.html b/lnbits/core/templates/core/extensions.html
index 97fa8936..daeb660f 100644
--- a/lnbits/core/templates/core/extensions.html
+++ b/lnbits/core/templates/core/extensions.html
@@ -17,14 +17,14 @@
>
{% raw %}
- Easy to set up and lightweight, LNbits can run on any - lightning-network funding source, currently supporting LND, - c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself! -
-- You can run LNbits for yourself, or easily offer a custodian solution - for others. -
-- Each wallet has its own API keys and there is no limit to the number - of wallets you can make. Being able to partition funds makes LNbits a - useful tool for money management and as a development tool. -
-- Extensions add extra functionality to LNbits so you can experiment - with a range of cutting-edge technologies on the lightning network. We - have made developing extensions as easy as possible, and as a free and - open-source project, we encourage people to develop and submit their - own. -
-+ Easy to set up and lightweight, LNbits can run on any + lightning-network funding source, currently supporting LND, + c-lightning, OpenNode, lntxbot, LNPay and even LNbits itself! +
++ You can run LNbits for yourself, or easily offer a custodian + solution for others. +
++ Each wallet has its own API keys and there is no limit to the number + of wallets you can make. Being able to partition funds makes LNbits + a useful tool for money management and as a development tool. +
++ Extensions add extra functionality to LNbits so you can experiment + with a range of cutting-edge technologies on the lightning network. + We have made developing extensions as easy as possible, and as a + free and open-source project, we encourage people to develop and + submit their own. +
+{{SITE_DESCRIPTION}}
- This is an LNURL-withdraw QR code for slurping everything from - this wallet. Do not share with anyone. -
- ++ This is an LNURL-withdraw QR code for slurping everything + from this wallet. Do not share with anyone. +
+ +
+ It is compatible with balanceCheck and
+ balanceNotify so your wallet may keep pulling
+ the funds continuously from here after the first withdraw.
+
+ This QR code contains your wallet URL with full access. You + can scan it from your phone to open your wallet from there. +
- It is compatible with balanceCheck and
- balanceNotify so your wallet may keep pulling the
- funds continuously from here after the first withdraw.
-
- This QR code contains your wallet URL with full access. You - can scan it from your phone to open your wallet from there. -
-- This whole wallet will be deleted, the funds will be - UNRECOVERABLE. -
-+ This whole wallet will be deleted, the funds will be + UNRECOVERABLE. +
+- {{receive.lnurl.domain}} is requesting an invoice: -
- -
- Description: {{ parse.invoice.description }}
- Expire date: {{ parse.invoice.expireDate }}
- Hash: {{ parse.invoice.hash }}
-
- Authenticate with {{ parse.lnurlauth.domain }}?
+
+ {{receive.lnurl.domain}} is requesting an invoice:
- For every website and for every LNbits wallet, a new keypair will be
- deterministically generated so your identity can't be tied to your
- LNbits wallet or linked across websites. No other data will be shared
- with {{ parse.lnurlauth.domain }}.
- Your public key for {{ parse.lnurlauth.domain }} is:
- {{ parse.lnurlauth.pubkey }}
-
- {{ parse.lnurlpay.domain }} is requesting {{
- parse.lnurlpay.maxSendable | msatoshiFormat }} sat
-
-
- and a {{parse.lnurlpay.commentAllowed}}-char comment
-
-
- {{ parse.lnurlpay.domain }} is requesting
- between {{ parse.lnurlpay.minSendable | msatoshiFormat }} and
- {{ parse.lnurlpay.maxSendable | msatoshiFormat }} sat
-
-
- and a {{parse.lnurlpay.commentAllowed}}-char comment
-
-
- {{ parse.lnurlpay.description }} -
-
-
+ Description: {{ parse.invoice.description }}
+ Expire date: {{ parse.invoice.expireDate }}
+ Hash: {{ parse.invoice.hash }}
+
+ Authenticate with {{ parse.lnurlauth.domain }}? +
++ For every website and for every LNbits wallet, a new keypair will be + deterministically generated so your identity can't be tied to your + LNbits wallet or linked across websites. No other data will be + shared with {{ parse.lnurlauth.domain }}. +
+Your public key for {{ parse.lnurlauth.domain }} is:
+
+ {{ parse.lnurlauth.pubkey }}
+
+ {{ parse.lnurlpay.domain }} is requesting {{
+ parse.lnurlpay.maxSendable | msatoshiFormat }}
+ {{LNBITS_DENOMINATION}}
+
+
+ and a {{parse.lnurlpay.commentAllowed}}-char comment
+
+
+ {{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }} is
+ requesting
+ between {{ parse.lnurlpay.minSendable | msatoshiFormat }} and
+ {{ parse.lnurlpay.maxSendable | msatoshiFormat }}
+ {% endraw %} {{LNBITS_DENOMINATION}} {% raw %}
+
+
+ and a {{parse.lnurlpay.commentAllowed}}-char comment
+
+
+ {{ parse.lnurlpay.description }} +
+
+
- Login functionality to be released in v0.2, for now, - make sure you bookmark this page for future access to your - wallet! -
-- This service is in BETA, and we hold no responsibility for people losing - access to funds. To encourage you to run your own LNbits installation, any - balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} will - incur a charge of {{ service_fee }}% service fee per - week. -
-+ Login functionality to be released in v0.2, for now, + make sure you bookmark this page for future access to your + wallet! +
++ This service is in BETA, and we hold no responsibility for people losing + access to funds. To encourage you to run your own LNbits installation, + any balance on {% raw %}{{ disclaimerDialog.location.host }}{% endraw %} + will incur a charge of + {{ service_fee }}% service fee per week. +
+", methods=["GET"])
-@api_check_wallet_key("invoice")
+@core_app.get(
+ "/api/v1/lnurlscan/{code}", dependencies=[Depends(WalletInvoiceKeyChecker())]
+)
async def api_lnurlscan(code: str):
try:
- url = lnurl.Lnurl(code)
- except ValueError:
- return jsonify({"message": "invalid lnurl"}), HTTPStatus.BAD_REQUEST
-
- domain = urlparse(url.url).netloc
+ url = lnurl.decode(code)
+ domain = urlparse(url).netloc
+ except:
+ # parse internet identifier (user@domain.com)
+ name_domain = code.split("@")
+ if len(name_domain) == 2 and len(name_domain[1].split(".")) == 2:
+ name, domain = name_domain
+ url = (
+ ("http://" if domain.endswith(".onion") else "https://")
+ + domain
+ + "/.well-known/lnurlp/"
+ + name
+ )
+ # will proceed with these values
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="invalid lnurl"
+ )
# params is what will be returned to the client
params: Dict = {"domain": domain}
- if url.is_login:
+ if "tag=login" in url:
params.update(kind="auth")
- params.update(callback=url.url) # with k1 already in it
+ params.update(callback=url) # with k1 already in it
- lnurlauth_key = g.wallet.lnurlauth_key(domain)
+ lnurlauth_key = g().wallet.lnurlauth_key(domain)
params.update(pubkey=lnurlauth_key.verifying_key.to_string("compressed").hex())
else:
async with httpx.AsyncClient() as client:
- r = await client.get(url.url, timeout=40)
+ r = await client.get(url, timeout=5)
if r.is_error:
- return (
- jsonify({"domain": domain, "message": "failed to get parameters"}),
- HTTPStatus.SERVICE_UNAVAILABLE,
+ raise HTTPException(
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE,
+ detail={"domain": domain, "message": "failed to get parameters"},
)
try:
- jdata = json.loads(r.text)
- data: lnurl.LnurlResponseModel = lnurl.LnurlResponse.from_dict(jdata)
- except (json.decoder.JSONDecodeError, lnurl.exceptions.LnurlResponseException):
- return (
- jsonify(
- {
+ data = json.loads(r.text)
+ except json.decoder.JSONDecodeError:
+ raise HTTPException(
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE,
+ detail={
+ "domain": domain,
+ "message": f"got invalid response '{r.text[:200]}'",
+ },
+ )
+
+ try:
+ tag = data["tag"]
+ if tag == "channelRequest":
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail={
"domain": domain,
- "message": f"got invalid response '{r.text[:200]}'",
- }
- ),
- HTTPStatus.SERVICE_UNAVAILABLE,
+ "kind": "channel",
+ "message": "unsupported",
+ },
+ )
+
+ params.update(**data)
+
+ if tag == "withdrawRequest":
+ params.update(kind="withdraw")
+ params.update(fixed=data["minWithdrawable"] == data["maxWithdrawable"])
+
+ # callback with k1 already in it
+ parsed_callback: ParseResult = urlparse(data["callback"])
+ qs: Dict = parse_qs(parsed_callback.query)
+ qs["k1"] = data["k1"]
+
+ # balanceCheck/balanceNotify
+ if "balanceCheck" in data:
+ params.update(balanceCheck=data["balanceCheck"])
+
+ # format callback url and send to client
+ parsed_callback = parsed_callback._replace(
+ query=urlencode(qs, doseq=True)
+ )
+ params.update(callback=urlunparse(parsed_callback))
+
+ if tag == "payRequest":
+ params.update(kind="pay")
+ params.update(fixed=data["minSendable"] == data["maxSendable"])
+
+ params.update(
+ description_hash=hashlib.sha256(
+ data["metadata"].encode("utf-8")
+ ).hexdigest()
+ )
+ metadata = json.loads(data["metadata"])
+ for [k, v] in metadata:
+ if k == "text/plain":
+ params.update(description=v)
+ if k == "image/jpeg;base64" or k == "image/png;base64":
+ data_uri = "data:" + k + "," + v
+ params.update(image=data_uri)
+ if k == "text/email" or k == "text/identifier":
+ params.update(targetUser=v)
+
+ params.update(commentAllowed=data.get("commentAllowed", 0))
+ except KeyError as exc:
+ raise HTTPException(
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE,
+ detail={
+ "domain": domain,
+ "message": f"lnurl JSON response invalid: {exc}",
+ },
)
- if type(data) is lnurl.LnurlChannelResponse:
- return (
- jsonify(
- {"domain": domain, "kind": "channel", "message": "unsupported"}
- ),
- HTTPStatus.BAD_REQUEST,
- )
-
- params.update(**data.dict())
-
- if type(data) is lnurl.LnurlWithdrawResponse:
- params.update(kind="withdraw")
- params.update(fixed=data.min_withdrawable == data.max_withdrawable)
-
- # callback with k1 already in it
- parsed_callback: ParseResult = urlparse(data.callback)
- qs: Dict = parse_qs(parsed_callback.query)
- qs["k1"] = data.k1
-
- # balanceCheck/balanceNotify
- if "balanceCheck" in jdata:
- params.update(balanceCheck=jdata["balanceCheck"])
-
- # format callback url and send to client
- parsed_callback = parsed_callback._replace(query=urlencode(qs, doseq=True))
- params.update(callback=urlunparse(parsed_callback))
-
- if type(data) is lnurl.LnurlPayResponse:
- params.update(kind="pay")
- params.update(fixed=data.min_sendable == data.max_sendable)
- params.update(description_hash=data.metadata.h)
- params.update(description=data.metadata.text)
- if data.metadata.images:
- image = min(data.metadata.images, key=lambda image: len(image[1]))
- data_uri = "data:" + image[0] + "," + image[1]
- params.update(image=data_uri)
- params.update(commentAllowed=jdata.get("commentAllowed", 0))
-
- return jsonify(params)
+ return params
-@core_app.route("/api/v1/lnurlauth", methods=["POST"])
-@api_check_wallet_key("admin")
-@api_validate_post_request(
- schema={
- "callback": {"type": "string", "required": True},
- }
-)
-async def api_perform_lnurlauth():
- err = await perform_lnurlauth(g.data["callback"])
+class DecodePayment(BaseModel):
+ data: str
+
+
+@core_app.post("/api/v1/payments/decode")
+async def api_payments_decode(data: DecodePayment):
+ payment_str = data.data
+ try:
+ if payment_str[:5] == "LNURL":
+ url = lnurl.decode(payment_str)
+ return {"domain": url}
+ else:
+ invoice = bolt11.decode(payment_str)
+ return {
+ "payment_hash": invoice.payment_hash,
+ "amount_msat": invoice.amount_msat,
+ "description": invoice.description,
+ "description_hash": invoice.description_hash,
+ "payee": invoice.payee,
+ "date": invoice.date,
+ "expiry": invoice.expiry,
+ "secret": invoice.secret,
+ "route_hints": invoice.route_hints,
+ "min_final_cltv_expiry": invoice.min_final_cltv_expiry,
+ }
+ except:
+ return {"message": "Failed to decode"}
+
+
+@core_app.post("/api/v1/lnurlauth", dependencies=[Depends(WalletAdminKeyChecker())])
+async def api_perform_lnurlauth(callback: str):
+ err = await perform_lnurlauth(callback)
if err:
- return jsonify({"reason": err.reason}), HTTPStatus.SERVICE_UNAVAILABLE
- return "", HTTPStatus.OK
+ raise HTTPException(
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail=err.reason
+ )
+
+ return ""
-@core_app.route("/api/v1/currencies", methods=["GET"])
+@core_app.get("/api/v1/currencies")
async def api_list_currencies_available():
- return jsonify(list(currencies.keys()))
+ return list(currencies.keys())
+
+
+class ConversionData(BaseModel):
+ from_: str = Field("sat", alias="from")
+ amount: float
+ to: str = Query("usd")
+
+
+@core_app.post("/api/v1/conversion")
+async def api_fiat_as_sats(data: ConversionData):
+ output = {}
+ if data.from_ == "sat":
+ output["sats"] = int(data.amount)
+ output["BTC"] = data.amount / 100000000
+ for currency in data.to.split(","):
+ output[currency.strip().upper()] = await satoshis_amount_as_fiat(
+ data.amount, currency.strip()
+ )
+ return output
+ else:
+ output[data.from_.upper()] = data.amount
+ output["sats"] = await fiat_amount_as_satoshis(data.amount, data.from_)
+ output["BTC"] = output["sats"] / 100000000
+ return output
diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py
index 16f64830..16a2fbac 100644
--- a/lnbits/core/views/generic.py
+++ b/lnbits/core/views/generic.py
@@ -1,156 +1,185 @@
-from os import path
+import asyncio
from http import HTTPStatus
-from quart import (
- g,
- current_app,
- abort,
- jsonify,
- request,
- redirect,
- render_template,
- send_from_directory,
- url_for,
-)
+from typing import Optional
-from lnbits.core import core_app, db
-from lnbits.decorators import check_user_exists, validate_uuids
-from lnbits.settings import LNBITS_ALLOWED_USERS, SERVICE_FEE, LNBITS_SITE_TITLE
+from fastapi import Request, status
+from fastapi.exceptions import HTTPException
+from fastapi.params import Depends, Query
+from fastapi.responses import FileResponse, RedirectResponse
+from fastapi.routing import APIRouter
+from pydantic.types import UUID4
+from starlette.responses import HTMLResponse, JSONResponse
+
+from lnbits.core import db
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+from lnbits.helpers import template_renderer, url_for
+from lnbits.settings import (
+ LNBITS_ADMIN_USERS,
+ LNBITS_ALLOWED_USERS,
+ LNBITS_SITE_TITLE,
+ SERVICE_FEE,
+)
from ..crud import (
create_account,
- get_user,
- update_user_extension,
create_wallet,
delete_wallet,
get_balance_check,
+ get_user,
save_balance_notify,
+ update_user_extension,
)
-from ..services import redeem_lnurl_withdraw, pay_invoice
+from ..services import pay_invoice, redeem_lnurl_withdraw
+
+core_html_routes: APIRouter = APIRouter(tags=["Core NON-API Website Routes"])
-@core_app.route("/favicon.ico")
+@core_html_routes.get("/favicon.ico", response_class=FileResponse)
async def favicon():
- return await send_from_directory(
- path.join(core_app.root_path, "static"), "favicon.ico"
+ return FileResponse("lnbits/core/static/favicon.ico")
+
+
+@core_html_routes.get("/", response_class=HTMLResponse)
+async def home(request: Request, lightning: str = None):
+ return template_renderer().TemplateResponse(
+ "core/index.html", {"request": request, "lnurl": lightning}
)
-@core_app.route("/")
-async def home():
- return await render_template(
- "core/index.html", lnurl=request.args.get("lightning", None)
- )
-
-
-@core_app.route("/extensions")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def extensions():
- extension_to_enable = request.args.get("enable", type=str)
- extension_to_disable = request.args.get("disable", type=str)
+@core_html_routes.get(
+ "/extensions", name="core.extensions", response_class=HTMLResponse
+)
+async def extensions(
+ request: Request,
+ user: User = Depends(check_user_exists),
+ enable: str = Query(None),
+ disable: str = Query(None),
+):
+ extension_to_enable = enable
+ extension_to_disable = disable
if extension_to_enable and extension_to_disable:
- abort(
+ raise HTTPException(
HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
)
if extension_to_enable:
await update_user_extension(
- user_id=g.user.id, extension=extension_to_enable, active=1
+ user_id=user.id, extension=extension_to_enable, active=True
)
elif extension_to_disable:
await update_user_extension(
- user_id=g.user.id, extension=extension_to_disable, active=0
+ user_id=user.id, extension=extension_to_disable, active=False
)
- return await render_template("core/extensions.html", user=await get_user(g.user.id))
+ # Update user as his extensions have been updated
+ if extension_to_enable or extension_to_disable:
+ user = await get_user(user.id)
+
+ return template_renderer().TemplateResponse(
+ "core/extensions.html", {"request": request, "user": user.dict()}
+ )
-@core_app.route("/wallet")
-@validate_uuids(["usr", "wal"])
-async def wallet():
- user_id = request.args.get("usr", type=str)
- wallet_id = request.args.get("wal", type=str)
- wallet_name = request.args.get("nme", type=str)
+@core_html_routes.get(
+ "/wallet",
+ response_class=HTMLResponse,
+ description="""
+Args:
+
+just **wallet_name**: create a new user, then create a new wallet for user with wallet_name
+just **user_id**: return the first user wallet or create one if none found (with default wallet_name)
+**user_id** and **wallet_name**: create a new wallet for user with wallet_name
+**user_id** and **wallet_id**: return that wallet if user is the owner
+nothing: create everything
+""",
+)
+async def wallet(
+ request: Request = Query(None),
+ nme: Optional[str] = Query(None),
+ usr: Optional[UUID4] = Query(None),
+ wal: Optional[UUID4] = Query(None),
+):
+ user_id = usr.hex if usr else None
+ wallet_id = wal.hex if wal else None
+ wallet_name = nme
service_fee = int(SERVICE_FEE) if int(SERVICE_FEE) == SERVICE_FEE else SERVICE_FEE
- # just wallet_name: create a new user, then create a new wallet for user with wallet_name
- # just user_id: return the first user wallet or create one if none found (with default wallet_name)
- # user_id and wallet_name: create a new wallet for user with wallet_name
- # user_id and wallet_id: return that wallet if user is the owner
- # nothing: create everything
-
if not user_id:
user = await get_user((await create_account()).id)
else:
user = await get_user(user_id)
if not user:
- abort(HTTPStatus.NOT_FOUND, "User does not exist.")
- return
-
+ return template_renderer().TemplateResponse(
+ "error.html", {"request": request, "err": "User does not exist."}
+ )
if LNBITS_ALLOWED_USERS and user_id not in LNBITS_ALLOWED_USERS:
- abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
-
+ return template_renderer().TemplateResponse(
+ "error.html", {"request": request, "err": "User not authorized."}
+ )
+ if LNBITS_ADMIN_USERS and user_id in LNBITS_ADMIN_USERS:
+ user.admin = True
if not wallet_id:
if user.wallets and not wallet_name:
wallet = user.wallets[0]
else:
wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
- return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
+ return RedirectResponse(
+ f"/wallet?usr={user.id}&wal={wallet.id}",
+ status_code=status.HTTP_307_TEMPORARY_REDIRECT,
+ )
wallet = user.get_wallet(wallet_id)
if not wallet:
- abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
+ return template_renderer().TemplateResponse(
+ "error.html", {"request": request, "err": "Wallet not found"}
+ )
- return await render_template(
- "core/wallet.html", user=user, wallet=wallet, service_fee=service_fee
- )
-
-
-@core_app.route("/withdraw")
-@validate_uuids(["usr", "wal"], required=True)
-async def lnurl_full_withdraw():
- user = await get_user(request.args.get("usr"))
- if not user:
- return jsonify({"status": "ERROR", "reason": "User does not exist."})
-
- wallet = user.get_wallet(request.args.get("wal"))
- if not wallet:
- return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
-
- return jsonify(
+ return template_renderer().TemplateResponse(
+ "core/wallet.html",
{
- "tag": "withdrawRequest",
- "callback": url_for(
- "core.lnurl_full_withdraw_callback",
- usr=user.id,
- wal=wallet.id,
- _external=True,
- ),
- "k1": "0",
- "minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
- "maxWithdrawable": wallet.withdrawable_balance,
- "defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
- "balanceCheck": url_for(
- "core.lnurl_full_withdraw", usr=user.id, wal=wallet.id, _external=True
- ),
- }
+ "request": request,
+ "user": user.dict(),
+ "wallet": wallet.dict(),
+ "service_fee": service_fee,
+ },
)
-@core_app.route("/withdraw/cb")
-@validate_uuids(["usr", "wal"], required=True)
-async def lnurl_full_withdraw_callback():
- user = await get_user(request.args.get("usr"))
+@core_html_routes.get("/withdraw", response_class=JSONResponse)
+async def lnurl_full_withdraw(request: Request):
+ user = await get_user(request.query_params.get("usr"))
if not user:
- return jsonify({"status": "ERROR", "reason": "User does not exist."})
+ return {"status": "ERROR", "reason": "User does not exist."}
- wallet = user.get_wallet(request.args.get("wal"))
+ wallet = user.get_wallet(request.query_params.get("wal"))
if not wallet:
- return jsonify({"status": "ERROR", "reason": "Wallet does not exist."})
+ return {"status": "ERROR", "reason": "Wallet does not exist."}
- pr = request.args.get("pr")
+ return {
+ "tag": "withdrawRequest",
+ "callback": url_for("/withdraw/cb", external=True, usr=user.id, wal=wallet.id),
+ "k1": "0",
+ "minWithdrawable": 1000 if wallet.withdrawable_balance else 0,
+ "maxWithdrawable": wallet.withdrawable_balance,
+ "defaultDescription": f"{LNBITS_SITE_TITLE} balance withdraw from {wallet.id[0:5]}",
+ "balanceCheck": url_for("/withdraw", external=True, usr=user.id, wal=wallet.id),
+ }
+
+
+@core_html_routes.get("/withdraw/cb", response_class=JSONResponse)
+async def lnurl_full_withdraw_callback(request: Request):
+ user = await get_user(request.query_params.get("usr"))
+ if not user:
+ return {"status": "ERROR", "reason": "User does not exist."}
+
+ wallet = user.get_wallet(request.query_params.get("wal"))
+ if not wallet:
+ return {"status": "ERROR", "reason": "Wallet does not exist."}
+
+ pr = request.query_params.get("pr")
async def pay():
try:
@@ -158,92 +187,97 @@ async def lnurl_full_withdraw_callback():
except:
pass
- current_app.nursery.start_soon(pay)
+ asyncio.create_task(pay())
- balance_notify = request.args.get("balanceNotify")
+ balance_notify = request.query_params.get("balanceNotify")
if balance_notify:
await save_balance_notify(wallet.id, balance_notify)
- return jsonify({"status": "OK"})
+ return {"status": "OK"}
-@core_app.route("/deletewallet")
-@validate_uuids(["usr", "wal"], required=True)
-@check_user_exists()
-async def deletewallet():
- wallet_id = request.args.get("wal", type=str)
- user_wallet_ids = g.user.wallet_ids
+@core_html_routes.get("/deletewallet", response_class=RedirectResponse)
+async def deletewallet(request: Request, wal: str = Query(...), usr: str = Query(...)):
+ user = await get_user(usr)
+ user_wallet_ids = [u.id for u in user.wallets]
+ print("USR", user_wallet_ids)
- if wallet_id not in user_wallet_ids:
- abort(HTTPStatus.FORBIDDEN, "Not your wallet.")
+ if wal not in user_wallet_ids:
+ raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
else:
- await delete_wallet(user_id=g.user.id, wallet_id=wallet_id)
- user_wallet_ids.remove(wallet_id)
+ await delete_wallet(user_id=user.id, wallet_id=wal)
+ user_wallet_ids.remove(wal)
if user_wallet_ids:
- return redirect(url_for("core.wallet", usr=g.user.id, wal=user_wallet_ids[0]))
+ return RedirectResponse(
+ url_for("/wallet", usr=user.id, wal=user_wallet_ids[0]),
+ status_code=status.HTTP_307_TEMPORARY_REDIRECT,
+ )
- return redirect(url_for("core.home"))
+ return RedirectResponse(
+ url_for("/"), status_code=status.HTTP_307_TEMPORARY_REDIRECT
+ )
-@core_app.route("/withdraw/notify/")
-@validate_uuids(["wal"], required=True)
-async def lnurl_balance_notify(service: str):
- bc = await get_balance_check(request.args.get("wal"), service)
+@core_html_routes.get("/withdraw/notify/{service}")
+async def lnurl_balance_notify(request: Request, service: str):
+ bc = await get_balance_check(request.query_params.get("wal"), service)
if bc:
redeem_lnurl_withdraw(bc.wallet, bc.url)
-@core_app.route("/lnurlwallet")
-async def lnurlwallet():
+@core_html_routes.get("/lnurlwallet", response_class=RedirectResponse, name="core.lnurlwallet")
+async def lnurlwallet(request: Request):
async with db.connect() as conn:
account = await create_account(conn=conn)
user = await get_user(account.id, conn=conn)
wallet = await create_wallet(user_id=user.id, conn=conn)
- current_app.nursery.start_soon(
- redeem_lnurl_withdraw,
- wallet.id,
- request.args.get("lightning"),
- "LNbits initial funding: voucher redeem.",
- {"tag": "lnurlwallet"},
- 5, # wait 5 seconds before sending the invoice to the service
+ asyncio.create_task(
+ redeem_lnurl_withdraw(
+ wallet.id,
+ request.query_params.get("lightning"),
+ "LNbits initial funding: voucher redeem.",
+ {"tag": "lnurlwallet"},
+ 5, # wait 5 seconds before sending the invoice to the service
+ )
)
- return redirect(url_for("core.wallet", usr=user.id, wal=wallet.id))
+ return RedirectResponse(
+ f"/wallet?usr={user.id}&wal={wallet.id}",
+ status_code=status.HTTP_307_TEMPORARY_REDIRECT,
+ )
-@core_app.route("/manifest/.webmanifest")
+@core_html_routes.get("/manifest/{usr}.webmanifest")
async def manifest(usr: str):
user = await get_user(usr)
if not user:
- return "", HTTPStatus.NOT_FOUND
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
- return jsonify(
- {
- "short_name": "LNbits",
- "name": "LNbits Wallet",
- "icons": [
- {
- "src": "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
- "type": "image/png",
- "sizes": "900x900",
- }
- ],
- "start_url": "/wallet?usr=" + usr,
- "background_color": "#3367D6",
- "description": "Weather forecast information",
- "display": "standalone",
- "scope": "/",
- "theme_color": "#3367D6",
- "shortcuts": [
- {
- "name": wallet.name,
- "short_name": wallet.name,
- "description": wallet.name,
- "url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
- }
- for wallet in user.wallets
- ],
- }
- )
+ return {
+ "short_name": "LNbits",
+ "name": "LNbits Wallet",
+ "icons": [
+ {
+ "src": "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png",
+ "type": "image/png",
+ "sizes": "900x900",
+ }
+ ],
+ "start_url": "/wallet?usr=" + usr,
+ "background_color": "#3367D6",
+ "description": "Weather forecast information",
+ "display": "standalone",
+ "scope": "/",
+ "theme_color": "#3367D6",
+ "shortcuts": [
+ {
+ "name": wallet.name,
+ "short_name": wallet.name,
+ "description": wallet.name,
+ "url": "/wallet?usr=" + usr + "&wal=" + wallet.id,
+ }
+ for wallet in user.wallets
+ ],
+ }
diff --git a/lnbits/core/views/public_api.py b/lnbits/core/views/public_api.py
index d25d7852..5f8be4e2 100644
--- a/lnbits/core/views/public_api.py
+++ b/lnbits/core/views/public_api.py
@@ -1,7 +1,11 @@
-import trio
+import asyncio
import datetime
from http import HTTPStatus
-from quart import jsonify
+from urllib.parse import urlparse
+
+from fastapi import HTTPException
+from starlette.requests import Request
+from starlette.responses import HTMLResponse
from lnbits import bolt11
@@ -10,28 +14,57 @@ from ..crud import get_standalone_payment
from ..tasks import api_invoice_listeners
-@core_app.route("/public/v1/payment/", methods=["GET"])
+@core_app.get("/.well-known/lnurlp/{username}")
+async def lnaddress(username: str, request: Request):
+ from lnbits.extensions.lnaddress.lnurl import lnurl_response
+
+ domain = urlparse(str(request.url)).netloc
+ return await lnurl_response(username, domain, request)
+
+
+@core_app.get("/public/v1/payment/{payment_hash}")
async def api_public_payment_longpolling(payment_hash):
payment = await get_standalone_payment(payment_hash)
if not payment:
- return jsonify({"message": "Payment does not exist."}), HTTPStatus.NOT_FOUND
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist."
+ )
elif not payment.pending:
- return jsonify({"status": "paid"}), HTTPStatus.OK
+ return {"status": "paid"}
try:
invoice = bolt11.decode(payment.bolt11)
expiration = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
if expiration < datetime.datetime.now():
- return jsonify({"status": "expired"}), HTTPStatus.OK
+ return {"status": "expired"}
except:
- return jsonify({"message": "Invalid bolt11 invoice."}), HTTPStatus.BAD_REQUEST
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="Invalid bolt11 invoice."
+ )
- send_payment, receive_payment = trio.open_memory_channel(0)
+ payment_queue = asyncio.Queue(0)
- print("adding standalone invoice listener", payment_hash, send_payment)
- api_invoice_listeners.append(send_payment)
+ print("adding standalone invoice listener", payment_hash, payment_queue)
+ api_invoice_listeners.append(payment_queue)
- async for payment in receive_payment:
- if payment.payment_hash == payment_hash:
- return jsonify({"status": "paid"}), HTTPStatus.OK
+ response = None
+
+ async def payment_info_receiver(cancel_scope):
+ async for payment in payment_queue.get():
+ if payment.payment_hash == payment_hash:
+ nonlocal response
+ response = {"status": "paid"}
+ cancel_scope.cancel()
+
+ async def timeouter(cancel_scope):
+ await asyncio.sleep(45)
+ cancel_scope.cancel()
+
+ asyncio.create_task(payment_info_receiver())
+ asyncio.create_task(timeouter())
+
+ if response:
+ return response
+ else:
+ raise HTTPException(status_code=HTTPStatus.REQUEST_TIMEOUT, detail="timeout")
diff --git a/lnbits/data/.gitignore b/lnbits/data/.gitignore
deleted file mode 100644
index d6b7ef32..00000000
--- a/lnbits/data/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/lnbits/db.py b/lnbits/db.py
index 9da3b6ec..7bbfa5c5 100644
--- a/lnbits/db.py
+++ b/lnbits/db.py
@@ -1,45 +1,173 @@
+import asyncio
+import datetime
import os
-import trio
+import time
from contextlib import asynccontextmanager
-from sqlalchemy import create_engine # type: ignore
-from sqlalchemy_aio import TRIO_STRATEGY # type: ignore
-from sqlalchemy_aio.base import AsyncConnection # type: ignore
+from typing import Optional
-from .settings import LNBITS_DATA_FOLDER
+from sqlalchemy import create_engine
+from sqlalchemy_aio.base import AsyncConnection
+from sqlalchemy_aio.strategy import ASYNCIO_STRATEGY # type: ignore
+
+from .settings import LNBITS_DATA_FOLDER, LNBITS_DATABASE_URL
+
+POSTGRES = "POSTGRES"
+COCKROACH = "COCKROACH"
+SQLITE = "SQLITE"
-class Connection:
- def __init__(self, conn: AsyncConnection):
+class Compat:
+ type: Optional[str] = ""
+ schema: Optional[str] = ""
+
+ def interval_seconds(self, seconds: int) -> str:
+ if self.type in {POSTGRES, COCKROACH}:
+ return f"interval '{seconds} seconds'"
+ elif self.type == SQLITE:
+ return f"{seconds}"
+ return ""
+
+ @property
+ def timestamp_now(self) -> str:
+ if self.type in {POSTGRES, COCKROACH}:
+ return "now()"
+ elif self.type == SQLITE:
+ return "(strftime('%s', 'now'))"
+ return ""
+
+ @property
+ def serial_primary_key(self) -> str:
+ if self.type in {POSTGRES, COCKROACH}:
+ return "SERIAL PRIMARY KEY"
+ elif self.type == SQLITE:
+ return "INTEGER PRIMARY KEY AUTOINCREMENT"
+ return ""
+
+ @property
+ def references_schema(self) -> str:
+ if self.type in {POSTGRES, COCKROACH}:
+ return f"{self.schema}."
+ elif self.type == SQLITE:
+ return ""
+ return ""
+
+
+class Connection(Compat):
+ def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
self.conn = conn
+ self.txn = txn
+ self.type = typ
+ self.name = name
+ self.schema = schema
+
+ def rewrite_query(self, query) -> str:
+ if self.type in {POSTGRES, COCKROACH}:
+ query = query.replace("%", "%%")
+ query = query.replace("?", "%s")
+ return query
async def fetchall(self, query: str, values: tuple = ()) -> list:
- result = await self.conn.execute(query, values)
+ result = await self.conn.execute(self.rewrite_query(query), values)
return await result.fetchall()
async def fetchone(self, query: str, values: tuple = ()):
- result = await self.conn.execute(query, values)
+ result = await self.conn.execute(self.rewrite_query(query), values)
row = await result.fetchone()
await result.close()
return row
async def execute(self, query: str, values: tuple = ()):
- return await self.conn.execute(query, values)
+ return await self.conn.execute(self.rewrite_query(query), values)
-class Database:
+class Database(Compat):
def __init__(self, db_name: str):
- self.db_name = db_name
- db_path = os.path.join(LNBITS_DATA_FOLDER, f"{db_name}.sqlite3")
- self.engine = create_engine(f"sqlite:///{db_path}", strategy=TRIO_STRATEGY)
- self.lock = trio.StrictFIFOLock()
+ self.name = db_name
+
+ if LNBITS_DATABASE_URL:
+ database_uri = LNBITS_DATABASE_URL
+
+ if database_uri.startswith("cockroachdb://"):
+ self.type = COCKROACH
+ else:
+ self.type = POSTGRES
+
+ import psycopg2 # type: ignore
+
+ def _parse_timestamp(value, _):
+ f = "%Y-%m-%d %H:%M:%S.%f"
+ if not "." in value:
+ f = "%Y-%m-%d %H:%M:%S"
+ return time.mktime(datetime.datetime.strptime(value, f).timetuple())
+
+ psycopg2.extensions.register_type(
+ psycopg2.extensions.new_type(
+ psycopg2.extensions.DECIMAL.values,
+ "DEC2FLOAT",
+ lambda value, curs: float(value) if value is not None else None,
+ )
+ )
+ psycopg2.extensions.register_type(
+ psycopg2.extensions.new_type(
+ (1082, 1083, 1266),
+ "DATE2INT",
+ lambda value, curs: time.mktime(value.timetuple())
+ if value is not None
+ else None,
+ )
+ )
+
+ psycopg2.extensions.register_type(
+ psycopg2.extensions.new_type(
+ (1184, 1114),
+ "TIMESTAMP2INT",
+ _parse_timestamp
+ # lambda value, curs: time.mktime(
+ # datetime.datetime.strptime(
+ # value, "%Y-%m-%d %H:%M:%S.%f"
+ # ).timetuple()
+ # ),
+ )
+ )
+ else:
+ if os.path.isdir(LNBITS_DATA_FOLDER):
+ self.path = os.path.join(LNBITS_DATA_FOLDER, f"{self.name}.sqlite3")
+ database_uri = f"sqlite:///{self.path}"
+ self.type = SQLITE
+ else:
+ raise NotADirectoryError(
+ f"LNBITS_DATA_FOLDER named {LNBITS_DATA_FOLDER} was not created"
+ f" - please 'mkdir {LNBITS_DATA_FOLDER}' and try again"
+ )
+
+ self.schema = self.name
+ if self.name.startswith("ext_"):
+ self.schema = self.name[4:]
+ else:
+ self.schema = None
+
+ self.engine = create_engine(database_uri, strategy=ASYNCIO_STRATEGY)
+ self.lock = asyncio.Lock()
@asynccontextmanager
async def connect(self):
await self.lock.acquire()
try:
async with self.engine.connect() as conn:
- async with conn.begin():
- yield Connection(conn)
+ async with conn.begin() as txn:
+ wconn = Connection(conn, txn, self.type, self.name, self.schema)
+
+ if self.schema:
+ if self.type in {POSTGRES, COCKROACH}:
+ await wconn.execute(
+ f"CREATE SCHEMA IF NOT EXISTS {self.schema}"
+ )
+ elif self.type == SQLITE:
+ await wconn.execute(
+ f"ATTACH '{self.path}' AS {self.schema}"
+ )
+
+ yield wconn
finally:
self.lock.release()
diff --git a/lnbits/decorators.py b/lnbits/decorators.py
index 5d923c35..76cb8a54 100644
--- a/lnbits/decorators.py
+++ b/lnbits/decorators.py
@@ -1,104 +1,217 @@
-from cerberus import Validator # type: ignore
-from quart import g, abort, jsonify, request
-from functools import wraps
from http import HTTPStatus
-from typing import List, Union
-from uuid import UUID
+
+from cerberus import Validator # type: ignore
+from fastapi import status
+from fastapi.exceptions import HTTPException
+from fastapi.openapi.models import APIKey, APIKeyIn
+from fastapi.params import Security
+from fastapi.security.api_key import APIKeyHeader, APIKeyQuery
+from fastapi.security.base import SecurityBase
+from pydantic.types import UUID4
+from starlette.requests import Request
from lnbits.core.crud import get_user, get_wallet_for_key
-from lnbits.settings import LNBITS_ALLOWED_USERS
+from lnbits.core.models import User, Wallet
+from lnbits.requestvars import g
+from lnbits.settings import LNBITS_ALLOWED_USERS, LNBITS_ADMIN_USERS, LNBITS_ADMIN_EXTENSIONS
-def api_check_wallet_key(key_type: str = "invoice", accept_querystring=False):
- def wrap(view):
- @wraps(view)
- async def wrapped_view(**kwargs):
- try:
- key_value = request.headers.get("X-Api-Key") or request.args["api-key"]
- g.wallet = await get_wallet_for_key(key_value, key_type)
- except KeyError:
- return (
- jsonify({"message": "`X-Api-Key` header missing."}),
- HTTPStatus.BAD_REQUEST,
+class KeyChecker(SecurityBase):
+ def __init__(
+ self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
+ ):
+ self.scheme_name = scheme_name or self.__class__.__name__
+ self.auto_error = auto_error
+ self._key_type = "invoice"
+ self._api_key = api_key
+ if api_key:
+ self.model: APIKey = APIKey(
+ **{"in": APIKeyIn.query},
+ name="X-API-KEY",
+ description="Wallet API Key - QUERY",
+ )
+ else:
+ self.model: APIKey = APIKey(
+ **{"in": APIKeyIn.header},
+ name="X-API-KEY",
+ description="Wallet API Key - HEADER",
+ )
+ self.wallet = None
+
+ async def __call__(self, request: Request) -> Wallet:
+ try:
+ key_value = (
+ self._api_key
+ if self._api_key
+ else request.headers.get("X-API-KEY") or request.query_params["api-key"]
+ )
+ # FIXME: Find another way to validate the key. A fetch from DB should be avoided here.
+ # Also, we should not return the wallet here - thats silly.
+ # Possibly store it in a Redis DB
+ self.wallet = await get_wallet_for_key(key_value, self._key_type)
+ if not self.wallet:
+ raise HTTPException(
+ status_code=HTTPStatus.UNAUTHORIZED,
+ detail="Invalid key or expired key.",
)
- if not g.wallet:
- return jsonify({"message": "Wrong keys."}), HTTPStatus.UNAUTHORIZED
-
- return await view(**kwargs)
-
- return wrapped_view
-
- return wrap
-
-
-def api_validate_post_request(*, schema: dict):
- def wrap(view):
- @wraps(view)
- async def wrapped_view(**kwargs):
- if "application/json" not in request.headers["Content-Type"]:
- return (
- jsonify({"message": "Content-Type must be `application/json`."}),
- HTTPStatus.BAD_REQUEST,
- )
-
- v = Validator(schema)
- data = await request.get_json()
- g.data = {key: data[key] for key in schema.keys() if key in data}
-
- if not v.validate(g.data):
- return (
- jsonify({"message": f"Errors in request data: {v.errors}"}),
- HTTPStatus.BAD_REQUEST,
- )
-
- return await view(**kwargs)
-
- return wrapped_view
-
- return wrap
-
-
-def check_user_exists(param: str = "usr"):
- def wrap(view):
- @wraps(view)
- async def wrapped_view(**kwargs):
- g.user = await get_user(request.args.get(param, type=str)) or abort(
- HTTPStatus.NOT_FOUND, "User does not exist."
+ except KeyError:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="`X-API-KEY` header missing."
)
- if LNBITS_ALLOWED_USERS and g.user.id not in LNBITS_ALLOWED_USERS:
- abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
- return await view(**kwargs)
+class WalletInvoiceKeyChecker(KeyChecker):
+ """
+ WalletInvoiceKeyChecker will ensure that the provided invoice
+ wallet key is correct and populate g().wallet with the wallet
+ for the key in `X-API-key`.
- return wrapped_view
+ The checker will raise an HTTPException when the key is wrong in some ways.
+ """
- return wrap
+ def __init__(
+ self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
+ ):
+ super().__init__(scheme_name, auto_error, api_key)
+ self._key_type = "invoice"
-def validate_uuids(
- params: List[str], *, required: Union[bool, List[str]] = False, version: int = 4
+class WalletAdminKeyChecker(KeyChecker):
+ """
+ WalletAdminKeyChecker will ensure that the provided admin
+ wallet key is correct and populate g().wallet with the wallet
+ for the key in `X-API-key`.
+
+ The checker will raise an HTTPException when the key is wrong in some ways.
+ """
+
+ def __init__(
+ self, scheme_name: str = None, auto_error: bool = True, api_key: str = None
+ ):
+ super().__init__(scheme_name, auto_error, api_key)
+ self._key_type = "admin"
+
+
+class WalletTypeInfo:
+ wallet_type: int
+ wallet: Wallet
+
+ def __init__(self, wallet_type: int, wallet: Wallet) -> None:
+ self.wallet_type = wallet_type
+ self.wallet = wallet
+
+
+api_key_header = APIKeyHeader(
+ name="X-API-KEY",
+ auto_error=False,
+ description="Admin or Invoice key for wallet API's",
+)
+api_key_query = APIKeyQuery(
+ name="api-key",
+ auto_error=False,
+ description="Admin or Invoice key for wallet API's",
+)
+
+
+async def get_key_type(
+ r: Request,
+ api_key_header: str = Security(api_key_header),
+ api_key_query: str = Security(api_key_query),
+) -> WalletTypeInfo:
+ # 0: admin
+ # 1: invoice
+ # 2: invalid
+ pathname = r['path'].split('/')[1]
+
+ if not api_key_header and not api_key_query:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST)
+
+ token = api_key_header if api_key_header else api_key_query
+
+ try:
+ checker = WalletAdminKeyChecker(api_key=token)
+ await checker.__call__(r)
+ wallet = WalletTypeInfo(0, checker.wallet)
+ if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
+ return wallet
+ except HTTPException as e:
+ if e.status_code == HTTPStatus.BAD_REQUEST:
+ raise
+ if e.status_code == HTTPStatus.UNAUTHORIZED:
+ pass
+ except:
+ raise
+
+ try:
+ checker = WalletInvoiceKeyChecker(api_key=token)
+ await checker.__call__(r)
+ wallet = WalletTypeInfo(1, checker.wallet)
+ if (LNBITS_ADMIN_USERS and wallet.wallet.user not in LNBITS_ADMIN_USERS) and (LNBITS_ADMIN_EXTENSIONS and pathname in LNBITS_ADMIN_EXTENSIONS):
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized.")
+ return wallet
+ except HTTPException as e:
+ if e.status_code == HTTPStatus.BAD_REQUEST:
+ raise
+ if e.status_code == HTTPStatus.UNAUTHORIZED:
+ return WalletTypeInfo(2, None)
+ except:
+ raise
+
+
+async def require_admin_key(
+ r: Request,
+ api_key_header: str = Security(api_key_header),
+ api_key_query: str = Security(api_key_query),
):
- def wrap(view):
- @wraps(view)
- async def wrapped_view(**kwargs):
- query_params = {
- param: request.args.get(param, type=str) for param in params
- }
+ token = api_key_header if api_key_header else api_key_query
- for param, value in query_params.items():
- if not value and (required is True or (required and param in required)):
- abort(HTTPStatus.BAD_REQUEST, f"`{param}` is required.")
+ wallet = await get_key_type(r, token)
- if value:
- try:
- UUID(value, version=version)
- except ValueError:
- abort(HTTPStatus.BAD_REQUEST, f"`{param}` is not a valid UUID.")
+ if wallet.wallet_type != 0:
+ # If wallet type is not admin then return the unauthorized status
+ # This also covers when the user passes an invalid key type
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
+ )
+ else:
+ return wallet
- return await view(**kwargs)
- return wrapped_view
+async def require_invoice_key(
+ r: Request,
+ api_key_header: str = Security(api_key_header),
+ api_key_query: str = Security(api_key_query),
+):
+ token = api_key_header if api_key_header else api_key_query
- return wrap
+ wallet = await get_key_type(r, token)
+
+ if wallet.wallet_type > 1:
+ # If wallet type is not invoice then return the unauthorized status
+ # This also covers when the user passes an invalid key type
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invoice (or Admin) key required.",
+ )
+ else:
+ return wallet
+
+
+async def check_user_exists(usr: UUID4) -> User:
+ g().user = await get_user(usr.hex)
+ if not g().user:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
+ )
+
+ if LNBITS_ALLOWED_USERS and g().user.id not in LNBITS_ALLOWED_USERS:
+ raise HTTPException(
+ status_code=HTTPStatus.UNAUTHORIZED, detail="User not authorized."
+ )
+
+ if LNBITS_ADMIN_USERS and g().user.id in LNBITS_ADMIN_USERS:
+ g().user.admin = True
+
+ return g().user
diff --git a/lnbits/extensions/amilk/README.md b/lnbits/extensions/amilk/README.md
deleted file mode 100644
index 27729459..00000000
--- a/lnbits/extensions/amilk/README.md
+++ /dev/null
@@ -1,11 +0,0 @@
-Example Extension
-*tagline*
-This is an example extension to help you organise and build you own.
-
-Try to include an image
-
-
-
-If your extension has API endpoints, include useful ones here
-
-curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"
diff --git a/lnbits/extensions/amilk/__init__.py b/lnbits/extensions/amilk/__init__.py
deleted file mode 100644
index 0cdd8727..00000000
--- a/lnbits/extensions/amilk/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from quart import Blueprint
-from lnbits.db import Database
-
-db = Database("ext_amilk")
-
-amilk_ext: Blueprint = Blueprint(
- "amilk", __name__, static_folder="static", template_folder="templates"
-)
-
-
-from .views_api import * # noqa
-from .views import * # noqa
diff --git a/lnbits/extensions/amilk/config.json b/lnbits/extensions/amilk/config.json
deleted file mode 100644
index 09faf8af..00000000
--- a/lnbits/extensions/amilk/config.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "name": "AMilk",
- "short_description": "Assistant Faucet Milker",
- "icon": "room_service",
- "contributors": ["arcbtc"]
-}
diff --git a/lnbits/extensions/amilk/crud.py b/lnbits/extensions/amilk/crud.py
deleted file mode 100644
index 773caa42..00000000
--- a/lnbits/extensions/amilk/crud.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from base64 import urlsafe_b64encode
-from uuid import uuid4
-from typing import List, Optional, Union
-
-from . import db
-from .models import AMilk
-
-
-async def create_amilk(*, wallet_id: str, lnurl: str, atime: int, amount: int) -> AMilk:
- amilk_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
- await db.execute(
- """
- INSERT INTO amilks (id, wallet, lnurl, atime, amount)
- VALUES (?, ?, ?, ?, ?)
- """,
- (amilk_id, wallet_id, lnurl, atime, amount),
- )
-
- amilk = await get_amilk(amilk_id)
- assert amilk, "Newly created amilk_id couldn't be retrieved"
- return amilk
-
-
-async def get_amilk(amilk_id: str) -> Optional[AMilk]:
- row = await db.fetchone("SELECT * FROM amilks WHERE id = ?", (amilk_id,))
- return AMilk(**row) if row else None
-
-
-async def get_amilks(wallet_ids: Union[str, List[str]]) -> List[AMilk]:
- if isinstance(wallet_ids, str):
- wallet_ids = [wallet_ids]
-
- q = ",".join(["?"] * len(wallet_ids))
- rows = await db.fetchall(
- f"SELECT * FROM amilks WHERE wallet IN ({q})", (*wallet_ids,)
- )
-
- return [AMilk(**row) for row in rows]
-
-
-async def delete_amilk(amilk_id: str) -> None:
- await db.execute("DELETE FROM amilks WHERE id = ?", (amilk_id,))
diff --git a/lnbits/extensions/amilk/migrations.py b/lnbits/extensions/amilk/migrations.py
deleted file mode 100644
index f096ccdb..00000000
--- a/lnbits/extensions/amilk/migrations.py
+++ /dev/null
@@ -1,15 +0,0 @@
-async def m001_initial(db):
- """
- Initial amilks table.
- """
- await db.execute(
- """
- CREATE TABLE IF NOT EXISTS amilks (
- id TEXT PRIMARY KEY,
- wallet TEXT NOT NULL,
- lnurl TEXT NOT NULL,
- atime INTEGER NOT NULL,
- amount INTEGER NOT NULL
- );
- """
- )
diff --git a/lnbits/extensions/amilk/models.py b/lnbits/extensions/amilk/models.py
deleted file mode 100644
index a2acfa88..00000000
--- a/lnbits/extensions/amilk/models.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from typing import NamedTuple
-
-
-class AMilk(NamedTuple):
- id: str
- wallet: str
- lnurl: str
- atime: int
- amount: int
diff --git a/lnbits/extensions/amilk/templates/amilk/_api_docs.html b/lnbits/extensions/amilk/templates/amilk/_api_docs.html
deleted file mode 100644
index f1c27a1b..00000000
--- a/lnbits/extensions/amilk/templates/amilk/_api_docs.html
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
- Assistant Faucet Milker
-
- Milking faucets with software, known as "assmilking", seems at first to
- be black-hat, although in fact there might be some unexplored use cases.
- An LNURL withdraw gives someone the right to pull funds, which can be
- done over time. An LNURL withdraw could be used outside of just faucets,
- to provide money streaming and repeat payments.
Paste or scan an
- LNURL withdraw, enter the amount for the AMilk to pull and the frequency
- for it to be pulled.
-
- Created by, Ben Arc
-
-
-
-
diff --git a/lnbits/extensions/amilk/templates/amilk/index.html b/lnbits/extensions/amilk/templates/amilk/index.html
deleted file mode 100644
index 357dd885..00000000
--- a/lnbits/extensions/amilk/templates/amilk/index.html
+++ /dev/null
@@ -1,250 +0,0 @@
-{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
-%} {% block page %}
-
-
-
-
- New AMilk
-
-
-
-
-
-
-
- AMilks
-
-
- Export to CSV
-
-
-
- {% raw %}
-
-
-
- {{ col.label }}
-
-
-
-
-
-
- {{ col.value }}
-
-
-
-
-
-
- {% endraw %}
-
-
-
-
-
-
-
-
-
- LNbits Assistant Faucet Milker Extension
-
-
-
-
- {% include "amilk/_api_docs.html" %}
-
-
-
-
-
-
-
-
-
-
-
-
- Create amilk
- Cancel
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }}
-
-{% endblock %}
diff --git a/lnbits/extensions/amilk/views.py b/lnbits/extensions/amilk/views.py
deleted file mode 100644
index 2f61df77..00000000
--- a/lnbits/extensions/amilk/views.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from quart import g, abort, render_template
-from http import HTTPStatus
-
-from lnbits.decorators import check_user_exists, validate_uuids
-
-from . import amilk_ext
-from .crud import get_amilk
-
-
-@amilk_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("amilk/index.html", user=g.user)
-
-
-@amilk_ext.route("/")
-async def wall(amilk_id):
- amilk = await get_amilk(amilk_id)
- if not amilk:
- abort(HTTPStatus.NOT_FOUND, "AMilk does not exist.")
-
- return await render_template("amilk/wall.html", amilk=amilk)
diff --git a/lnbits/extensions/amilk/views_api.py b/lnbits/extensions/amilk/views_api.py
deleted file mode 100644
index 4b8cad18..00000000
--- a/lnbits/extensions/amilk/views_api.py
+++ /dev/null
@@ -1,105 +0,0 @@
-import httpx
-from quart import g, jsonify, request, abort
-from http import HTTPStatus
-from lnurl import LnurlWithdrawResponse, handle as handle_lnurl # type: ignore
-from lnurl.exceptions import LnurlException # type: ignore
-from time import sleep
-
-from lnbits.core.crud import get_user
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
-from lnbits.core.services import create_invoice, check_invoice_status
-
-from . import amilk_ext
-from .crud import create_amilk, get_amilk, get_amilks, delete_amilk
-
-
-@amilk_ext.route("/api/v1/amilk", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_amilks():
- wallet_ids = [g.wallet.id]
-
- if "all_wallets" in request.args:
- wallet_ids = (await get_user(g.wallet.user)).wallet_ids
-
- return (
- jsonify([amilk._asdict() for amilk in await get_amilks(wallet_ids)]),
- HTTPStatus.OK,
- )
-
-
-@amilk_ext.route("/api/v1/amilk/milk/", methods=["GET"])
-async def api_amilkit(amilk_id):
- milk = await get_amilk(amilk_id)
- memo = milk.id
-
- try:
- withdraw_res = handle_lnurl(milk.lnurl, response_class=LnurlWithdrawResponse)
- except LnurlException:
- abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
-
- try:
- payment_hash, payment_request = await create_invoice(
- wallet_id=milk.wallet,
- amount=withdraw_res.max_sats,
- memo=memo,
- extra={"tag": "amilk"},
- )
- except Exception as e:
- return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
-
- r = httpx.get(
- withdraw_res.callback.base,
- params={
- **withdraw_res.callback.query_params,
- **{"k1": withdraw_res.k1, "pr": payment_request},
- },
- )
-
- if r.is_error:
- abort(HTTPStatus.INTERNAL_SERVER_ERROR, "Could not process withdraw LNURL.")
-
- for i in range(10):
- sleep(i)
- invoice_status = await check_invoice_status(milk.wallet, payment_hash)
- if invoice_status.paid:
- return jsonify({"paid": True}), HTTPStatus.OK
- else:
- continue
-
- return jsonify({"paid": False}), HTTPStatus.OK
-
-
-@amilk_ext.route("/api/v1/amilk", methods=["POST"])
-@api_check_wallet_key("invoice")
-@api_validate_post_request(
- schema={
- "lnurl": {"type": "string", "empty": False, "required": True},
- "atime": {"type": "integer", "min": 0, "required": True},
- "amount": {"type": "integer", "min": 0, "required": True},
- }
-)
-async def api_amilk_create():
- amilk = await create_amilk(
- wallet_id=g.wallet.id,
- lnurl=g.data["lnurl"],
- atime=g.data["atime"],
- amount=g.data["amount"],
- )
-
- return jsonify(amilk._asdict()), HTTPStatus.CREATED
-
-
-@amilk_ext.route("/api/v1/amilk/", methods=["DELETE"])
-@api_check_wallet_key("invoice")
-async def api_amilk_delete(amilk_id):
- amilk = await get_amilk(amilk_id)
-
- if not amilk:
- return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
-
- if amilk.wallet != g.wallet.id:
- return jsonify({"message": "Not your amilk."}), HTTPStatus.FORBIDDEN
-
- await delete_amilk(amilk_id)
-
- return "", HTTPStatus.NO_CONTENT
diff --git a/lnbits/extensions/bleskomat/__init__.py b/lnbits/extensions/bleskomat/__init__.py
index 42f9bb46..cc89760e 100644
--- a/lnbits/extensions/bleskomat/__init__.py
+++ b/lnbits/extensions/bleskomat/__init__.py
@@ -1,12 +1,26 @@
-from quart import Blueprint
+from fastapi import APIRouter
+from starlette.staticfiles import StaticFiles
+
from lnbits.db import Database
+from lnbits.helpers import template_renderer
db = Database("ext_bleskomat")
-bleskomat_ext: Blueprint = Blueprint(
- "bleskomat", __name__, static_folder="static", template_folder="templates"
-)
+bleskomat_static_files = [
+ {
+ "path": "/bleskomat/static",
+ "app": StaticFiles(directory="lnbits/extensions/bleskomat/static"),
+ "name": "bleskomat_static",
+ }
+]
+
+bleskomat_ext: APIRouter = APIRouter(prefix="/bleskomat", tags=["Bleskomat"])
+
+
+def bleskomat_renderer():
+ return template_renderer(["lnbits/extensions/bleskomat/templates"])
+
from .lnurl_api import * # noqa
-from .views_api import * # noqa
from .views import * # noqa
+from .views_api import * # noqa
diff --git a/lnbits/extensions/bleskomat/crud.py b/lnbits/extensions/bleskomat/crud.py
index 206cec1f..37af56cb 100644
--- a/lnbits/extensions/bleskomat/crud.py
+++ b/lnbits/extensions/bleskomat/crud.py
@@ -1,27 +1,21 @@
import secrets
import time
-from uuid import uuid4
from typing import List, Optional, Union
+from uuid import uuid4
+
from . import db
-from .models import Bleskomat, BleskomatLnurl
from .helpers import generate_bleskomat_lnurl_hash
+from .models import Bleskomat, BleskomatLnurl, CreateBleskomat
-async def create_bleskomat(
- *,
- wallet_id: str,
- name: str,
- fiat_currency: str,
- exchange_rate_provider: str,
- fee: str,
-) -> Bleskomat:
+async def create_bleskomat(data: CreateBleskomat, wallet_id: str) -> Bleskomat:
bleskomat_id = uuid4().hex
api_key_id = secrets.token_hex(8)
api_key_secret = secrets.token_hex(32)
api_key_encoding = "hex"
await db.execute(
"""
- INSERT INTO bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
+ INSERT INTO bleskomat.bleskomats (id, wallet, api_key_id, api_key_secret, api_key_encoding, name, fiat_currency, exchange_rate_provider, fee)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
@@ -30,10 +24,10 @@ async def create_bleskomat(
api_key_id,
api_key_secret,
api_key_encoding,
- name,
- fiat_currency,
- exchange_rate_provider,
- fee,
+ data.name,
+ data.fiat_currency,
+ data.exchange_rate_provider,
+ data.fee,
),
)
bleskomat = await get_bleskomat(bleskomat_id)
@@ -42,13 +36,15 @@ async def create_bleskomat(
async def get_bleskomat(bleskomat_id: str) -> Optional[Bleskomat]:
- row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
+ row = await db.fetchone(
+ "SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
+ )
return Bleskomat(**row) if row else None
async def get_bleskomat_by_api_key_id(api_key_id: str) -> Optional[Bleskomat]:
row = await db.fetchone(
- "SELECT * FROM bleskomats WHERE api_key_id = ?", (api_key_id,)
+ "SELECT * FROM bleskomat.bleskomats WHERE api_key_id = ?", (api_key_id,)
)
return Bleskomat(**row) if row else None
@@ -58,7 +54,7 @@ async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
- f"SELECT * FROM bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
+ f"SELECT * FROM bleskomat.bleskomats WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Bleskomat(**row) for row in rows]
@@ -66,14 +62,17 @@ async def get_bleskomats(wallet_ids: Union[str, List[str]]) -> List[Bleskomat]:
async def update_bleskomat(bleskomat_id: str, **kwargs) -> Optional[Bleskomat]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
- f"UPDATE bleskomats SET {q} WHERE id = ?", (*kwargs.values(), bleskomat_id)
+ f"UPDATE bleskomat.bleskomats SET {q} WHERE id = ?",
+ (*kwargs.values(), bleskomat_id),
+ )
+ row = await db.fetchone(
+ "SELECT * FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,)
)
- row = await db.fetchone("SELECT * FROM bleskomats WHERE id = ?", (bleskomat_id,))
return Bleskomat(**row) if row else None
async def delete_bleskomat(bleskomat_id: str) -> None:
- await db.execute("DELETE FROM bleskomats WHERE id = ?", (bleskomat_id,))
+ await db.execute("DELETE FROM bleskomat.bleskomats WHERE id = ?", (bleskomat_id,))
async def create_bleskomat_lnurl(
@@ -84,7 +83,7 @@ async def create_bleskomat_lnurl(
now = int(time.time())
await db.execute(
"""
- INSERT INTO bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
+ INSERT INTO bleskomat.bleskomat_lnurls (id, bleskomat, wallet, hash, tag, params, api_key_id, initial_uses, remaining_uses, created_time, updated_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
@@ -108,5 +107,7 @@ async def create_bleskomat_lnurl(
async def get_bleskomat_lnurl(secret: str) -> Optional[BleskomatLnurl]:
hash = generate_bleskomat_lnurl_hash(secret)
- row = await db.fetchone("SELECT * FROM bleskomat_lnurls WHERE hash = ?", (hash,))
+ row = await db.fetchone(
+ "SELECT * FROM bleskomat.bleskomat_lnurls WHERE hash = ?", (hash,)
+ )
return BleskomatLnurl(**row) if row else None
diff --git a/lnbits/extensions/bleskomat/exchange_rates.py b/lnbits/extensions/bleskomat/exchange_rates.py
index 928a2823..dcdaa220 100644
--- a/lnbits/extensions/bleskomat/exchange_rates.py
+++ b/lnbits/extensions/bleskomat/exchange_rates.py
@@ -65,15 +65,16 @@ async def fetch_fiat_exchange_rate(currency: str, provider: str):
}
url = exchange_rate_providers[provider]["api_url"]
- for key in replacements.keys():
- url = url.replace("{" + key + "}", replacements[key])
+ if url:
+ for key in replacements.keys():
+ url = url.replace("{" + key + "}", replacements[key])
+ async with httpx.AsyncClient() as client:
+ r = await client.get(url)
+ r.raise_for_status()
+ data = r.json()
+ else:
+ data = {}
getter = exchange_rate_providers[provider]["getter"]
-
- async with httpx.AsyncClient() as client:
- r = await client.get(url)
- r.raise_for_status()
- data = r.json()
- rate = float(getter(data, replacements))
-
+ rate = float(getter(data, replacements))
return rate
diff --git a/lnbits/extensions/bleskomat/helpers.py b/lnbits/extensions/bleskomat/helpers.py
index a3857b77..6e55b3df 100644
--- a/lnbits/extensions/bleskomat/helpers.py
+++ b/lnbits/extensions/bleskomat/helpers.py
@@ -1,11 +1,12 @@
import base64
import hashlib
import hmac
-from http import HTTPStatus
-from binascii import unhexlify
-from typing import Dict
-from quart import url_for
import urllib
+from binascii import unhexlify
+from http import HTTPStatus
+from typing import Dict
+
+from starlette.requests import Request
def generate_bleskomat_lnurl_hash(secret: str):
@@ -34,8 +35,8 @@ def generate_bleskomat_lnurl_secret(api_key_id: str, signature: str):
return m.hexdigest()
-def get_callback_url():
- return url_for("bleskomat.api_bleskomat_lnurl", _external=True)
+def get_callback_url(req: Request):
+ return req.url_for("bleskomat.api_bleskomat_lnurl")
def is_supported_lnurl_subprotocol(tag: str) -> bool:
diff --git a/lnbits/extensions/bleskomat/lnurl_api.py b/lnbits/extensions/bleskomat/lnurl_api.py
index 086562d1..25ff0412 100644
--- a/lnbits/extensions/bleskomat/lnurl_api.py
+++ b/lnbits/extensions/bleskomat/lnurl_api.py
@@ -1,8 +1,9 @@
import json
import math
-from quart import jsonify, request
-from http import HTTPStatus
import traceback
+from http import HTTPStatus
+
+from starlette.requests import Request
from . import bleskomat_ext
from .crud import (
@@ -10,16 +11,12 @@ from .crud import (
get_bleskomat_by_api_key_id,
get_bleskomat_lnurl,
)
-
-from .exchange_rates import (
- fetch_fiat_exchange_rate,
-)
-
+from .exchange_rates import fetch_fiat_exchange_rate
from .helpers import (
- generate_bleskomat_lnurl_signature,
- generate_bleskomat_lnurl_secret,
LnurlHttpError,
LnurlValidationError,
+ generate_bleskomat_lnurl_secret,
+ generate_bleskomat_lnurl_signature,
prepare_lnurl_params,
query_to_signing_payload,
unshorten_lnurl_query,
@@ -27,10 +24,10 @@ from .helpers import (
# Handles signed URL from Bleskomat ATMs and "action" callback of auto-generated LNURLs.
-@bleskomat_ext.route("/u", methods=["GET"])
-async def api_bleskomat_lnurl():
+@bleskomat_ext.get("/u", name="bleskomat.api_bleskomat_lnurl")
+async def api_bleskomat_lnurl(req: Request):
try:
- query = request.args.to_dict()
+ query = req.query_params
# Unshorten query if "s" is used instead of "signature".
if "s" in query:
@@ -99,7 +96,7 @@ async def api_bleskomat_lnurl():
)
# Reply with LNURL response object.
- return jsonify(lnurl.get_info_response_object(secret)), HTTPStatus.OK
+ return lnurl.get_info_response_object(secret, req)
# No signature provided.
# Treat as "action" callback.
@@ -123,12 +120,9 @@ async def api_bleskomat_lnurl():
raise LnurlHttpError(str(e), HTTPStatus.BAD_REQUEST)
except LnurlHttpError as e:
- return jsonify({"status": "ERROR", "reason": str(e)}), e.http_status
- except Exception:
- traceback.print_exc()
- return (
- jsonify({"status": "ERROR", "reason": "Unexpected error"}),
- HTTPStatus.INTERNAL_SERVER_ERROR,
- )
+ return {"status": "ERROR", "reason": str(e)}
+ except Exception as e:
+ print(str(e))
+ return {"status": "ERROR", "reason": "Unexpected error"}
- return jsonify({"status": "OK"}), HTTPStatus.OK
+ return {"status": "OK"}
diff --git a/lnbits/extensions/bleskomat/migrations.py b/lnbits/extensions/bleskomat/migrations.py
index f7956500..84e886e5 100644
--- a/lnbits/extensions/bleskomat/migrations.py
+++ b/lnbits/extensions/bleskomat/migrations.py
@@ -2,7 +2,7 @@ async def m001_initial(db):
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS bleskomats (
+ CREATE TABLE bleskomat.bleskomats (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
api_key_id TEXT NOT NULL,
@@ -19,7 +19,7 @@ async def m001_initial(db):
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS bleskomat_lnurls (
+ CREATE TABLE bleskomat.bleskomat_lnurls (
id TEXT PRIMARY KEY,
bleskomat TEXT NOT NULL,
wallet TEXT NOT NULL,
diff --git a/lnbits/extensions/bleskomat/models.py b/lnbits/extensions/bleskomat/models.py
index 54633146..267cc949 100644
--- a/lnbits/extensions/bleskomat/models.py
+++ b/lnbits/extensions/bleskomat/models.py
@@ -1,13 +1,45 @@
import json
import time
-from typing import NamedTuple, Dict
+from typing import Dict
+
+from fastapi.params import Query
+from pydantic import BaseModel, validator
+from starlette.requests import Request
+
from lnbits import bolt11
-from lnbits.core.services import pay_invoice
+from lnbits.core.services import pay_invoice, PaymentFailure
+
from . import db
-from .helpers import get_callback_url, LnurlValidationError
+from .exchange_rates import exchange_rate_providers, fiat_currencies
+from .helpers import LnurlValidationError, get_callback_url
-class Bleskomat(NamedTuple):
+class CreateBleskomat(BaseModel):
+ name: str = Query(...)
+ fiat_currency: str = Query(...)
+ exchange_rate_provider: str = Query(...)
+ fee: str = Query(...)
+
+ @validator("fiat_currency")
+ def allowed_fiat_currencies(cls, v):
+ if v not in fiat_currencies.keys():
+ raise ValueError("Not allowed currency")
+ return v
+
+ @validator("exchange_rate_provider")
+ def allowed_providers(cls, v):
+ if v not in exchange_rate_providers.keys():
+ raise ValueError("Not allowed provider")
+ return v
+
+ @validator("fee")
+ def fee_type(cls, v):
+ if not isinstance(v, (str, float, int)):
+ raise ValueError("Fee type not allowed")
+ return v
+
+
+class Bleskomat(BaseModel):
id: str
wallet: str
api_key_id: str
@@ -19,7 +51,7 @@ class Bleskomat(NamedTuple):
fee: str
-class BleskomatLnurl(NamedTuple):
+class BleskomatLnurl(BaseModel):
id: str
bleskomat: str
wallet: str
@@ -36,14 +68,14 @@ class BleskomatLnurl(NamedTuple):
# When initial uses is 0 then the LNURL has unlimited uses.
return self.initial_uses == 0 or self.remaining_uses > 0
- def get_info_response_object(self, secret: str) -> Dict[str, str]:
+ def get_info_response_object(self, secret: str, req: Request) -> Dict[str, str]:
tag = self.tag
params = json.loads(self.params)
response = {"tag": tag}
if tag == "withdrawRequest":
for key in ["minWithdrawable", "maxWithdrawable", "defaultDescription"]:
response[key] = params[key]
- response["callback"] = get_callback_url()
+ response["callback"] = get_callback_url(req)
response["k1"] = secret
return response
@@ -87,20 +119,20 @@ class BleskomatLnurl(NamedTuple):
tag = self.tag
if tag == "withdrawRequest":
try:
- payment_hash = await pay_invoice(
- wallet_id=self.wallet,
- payment_request=query["pr"],
+ await pay_invoice(
+ wallet_id=self.wallet, payment_request=query["pr"]
)
- except Exception:
- raise LnurlValidationError("Failed to pay invoice")
- if not payment_hash:
- raise LnurlValidationError("Failed to pay invoice")
+ except (ValueError, PermissionError, PaymentFailure) as e:
+ raise LnurlValidationError("Failed to pay invoice: " + str(e))
+ except Exception as e:
+ print(str(e))
+ raise LnurlValidationError("Unexpected error")
async def use(self, conn) -> bool:
now = int(time.time())
result = await conn.execute(
"""
- UPDATE bleskomat_lnurls
+ UPDATE bleskomat.bleskomat_lnurls
SET remaining_uses = remaining_uses - 1, updated_time = ?
WHERE id = ?
AND remaining_uses > 0
diff --git a/lnbits/extensions/bleskomat/static/js/index.js b/lnbits/extensions/bleskomat/static/js/index.js
index fd166ff3..f20a4659 100644
--- a/lnbits/extensions/bleskomat/static/js/index.js
+++ b/lnbits/extensions/bleskomat/static/js/index.js
@@ -84,7 +84,7 @@ new Vue({
LNbits.api
.request(
'GET',
- '/bleskomat/api/v1/bleskomats?all_wallets',
+ '/bleskomat/api/v1/bleskomats?all_wallets=true',
this.g.user.wallets[0].adminkey
)
.then(function (response) {
diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html
index 50431c41..210d534c 100644
--- a/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html
+++ b/lnbits/extensions/bleskomat/templates/bleskomat/_api_docs.html
@@ -1,7 +1,7 @@
diff --git a/lnbits/extensions/bleskomat/templates/bleskomat/index.html b/lnbits/extensions/bleskomat/templates/bleskomat/index.html
index d00937c1..0cc51237 100644
--- a/lnbits/extensions/bleskomat/templates/bleskomat/index.html
+++ b/lnbits/extensions/bleskomat/templates/bleskomat/index.html
@@ -11,7 +11,7 @@
- Add Bleskomat
@@ -94,7 +94,9 @@
- LNbits Bleskomat extension
+
+ {{SITE_TITLE}} Bleskomat extension
+
@@ -150,14 +152,14 @@
Update Bleskomat
", methods=["GET"])
-@api_check_wallet_key("admin")
-async def api_bleskomat_retrieve(bleskomat_id):
+@bleskomat_ext.get("/api/v1/bleskomat/{bleskomat_id}")
+async def api_bleskomat_retrieve(
+ bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)
+):
bleskomat = await get_bleskomat(bleskomat_id)
- if not bleskomat or bleskomat.wallet != g.wallet.id:
- return (
- jsonify({"message": "Bleskomat configuration not found."}),
- HTTPStatus.NOT_FOUND,
+ if not bleskomat or bleskomat.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Bleskomat configuration not found.",
)
- return jsonify(bleskomat._asdict()), HTTPStatus.OK
+ return bleskomat.dict()
-@bleskomat_ext.route("/api/v1/bleskomat", methods=["POST"])
-@bleskomat_ext.route("/api/v1/bleskomat/", methods=["PUT"])
-@api_check_wallet_key("admin")
-@api_validate_post_request(
- schema={
- "name": {"type": "string", "empty": False, "required": True},
- "fiat_currency": {
- "type": "string",
- "allowed": fiat_currencies.keys(),
- "required": True,
- },
- "exchange_rate_provider": {
- "type": "string",
- "allowed": exchange_rate_providers.keys(),
- "required": True,
- },
- "fee": {"type": ["string", "float", "number", "integer"], "required": True},
- }
-)
-async def api_bleskomat_create_or_update(bleskomat_id=None):
+@bleskomat_ext.post("/api/v1/bleskomat")
+@bleskomat_ext.put("/api/v1/bleskomat/{bleskomat_id}")
+async def api_bleskomat_create_or_update(
+ data: CreateBleskomat,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+ bleskomat_id=None,
+):
try:
- fiat_currency = g.data["fiat_currency"]
- exchange_rate_provider = g.data["exchange_rate_provider"]
+ fiat_currency = data.fiat_currency
+ exchange_rate_provider = data.exchange_rate_provider
await fetch_fiat_exchange_rate(
currency=fiat_currency, provider=exchange_rate_provider
)
except Exception as e:
print(e)
- return (
- jsonify(
- {
- "message": f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"'
- }
- ),
- HTTPStatus.INTERNAL_SERVER_ERROR,
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ detail=f'Failed to fetch BTC/{fiat_currency} currency pair from "{exchange_rate_provider}"',
)
if bleskomat_id:
bleskomat = await get_bleskomat(bleskomat_id)
- if not bleskomat or bleskomat.wallet != g.wallet.id:
- return (
- jsonify({"message": "Bleskomat configuration not found."}),
- HTTPStatus.NOT_FOUND,
+ if not bleskomat or bleskomat.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Bleskomat configuration not found.",
)
- bleskomat = await update_bleskomat(bleskomat_id, **g.data)
+
+ bleskomat = await update_bleskomat(bleskomat_id, **data.dict())
else:
- bleskomat = await create_bleskomat(wallet_id=g.wallet.id, **g.data)
+ bleskomat = await create_bleskomat(wallet_id=wallet.wallet.id, data=data)
- return (
- jsonify(bleskomat._asdict()),
- HTTPStatus.OK if bleskomat_id else HTTPStatus.CREATED,
- )
+ return bleskomat.dict()
-@bleskomat_ext.route("/api/v1/bleskomat/", methods=["DELETE"])
-@api_check_wallet_key("admin")
-async def api_bleskomat_delete(bleskomat_id):
+@bleskomat_ext.delete("/api/v1/bleskomat/{bleskomat_id}")
+async def api_bleskomat_delete(
+ bleskomat_id, wallet: WalletTypeInfo = Depends(require_admin_key)
+):
bleskomat = await get_bleskomat(bleskomat_id)
- if not bleskomat or bleskomat.wallet != g.wallet.id:
- return (
- jsonify({"message": "Bleskomat configuration not found."}),
- HTTPStatus.NOT_FOUND,
+ if not bleskomat or bleskomat.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Bleskomat configuration not found.",
)
await delete_bleskomat(bleskomat_id)
-
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
diff --git a/lnbits/extensions/captcha/README.md b/lnbits/extensions/captcha/README.md
deleted file mode 100644
index 27729459..00000000
--- a/lnbits/extensions/captcha/README.md
+++ /dev/null
@@ -1,11 +0,0 @@
-Example Extension
-*tagline*
-This is an example extension to help you organise and build you own.
-
-Try to include an image
-
-
-
-If your extension has API endpoints, include useful ones here
-
-curl -H "Content-type: application/json" -X POST https://YOUR-LNBITS/YOUR-EXTENSION/api/v1/EXAMPLE -d '{"amount":"100","memo":"example"}' -H "X-Api-Key: YOUR_WALLET-ADMIN/INVOICE-KEY"
diff --git a/lnbits/extensions/captcha/__init__.py b/lnbits/extensions/captcha/__init__.py
deleted file mode 100644
index f25dccce..00000000
--- a/lnbits/extensions/captcha/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from quart import Blueprint
-from lnbits.db import Database
-
-db = Database("ext_captcha")
-
-captcha_ext: Blueprint = Blueprint(
- "captcha", __name__, static_folder="static", template_folder="templates"
-)
-
-
-from .views_api import * # noqa
-from .views import * # noqa
diff --git a/lnbits/extensions/captcha/config.json b/lnbits/extensions/captcha/config.json
deleted file mode 100644
index 4ef7c43f..00000000
--- a/lnbits/extensions/captcha/config.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "name": "Captcha",
- "short_description": "Create captcha to stop spam",
- "icon": "block",
- "contributors": ["pseudozach"]
-}
diff --git a/lnbits/extensions/captcha/crud.py b/lnbits/extensions/captcha/crud.py
deleted file mode 100644
index 7526306b..00000000
--- a/lnbits/extensions/captcha/crud.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from typing import List, Optional, Union
-
-from lnbits.helpers import urlsafe_short_hash
-
-from . import db
-from .models import Captcha
-
-
-async def create_captcha(
- *,
- wallet_id: str,
- url: str,
- memo: str,
- description: Optional[str] = None,
- amount: int = 0,
- remembers: bool = True,
-) -> Captcha:
- captcha_id = urlsafe_short_hash()
- await db.execute(
- """
- INSERT INTO captchas (id, wallet, url, memo, description, amount, remembers)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- """,
- (captcha_id, wallet_id, url, memo, description, amount, int(remembers)),
- )
-
- captcha = await get_captcha(captcha_id)
- assert captcha, "Newly created captcha couldn't be retrieved"
- return captcha
-
-
-async def get_captcha(captcha_id: str) -> Optional[Captcha]:
- row = await db.fetchone("SELECT * FROM captchas WHERE id = ?", (captcha_id,))
-
- return Captcha.from_row(row) if row else None
-
-
-async def get_captchas(wallet_ids: Union[str, List[str]]) -> List[Captcha]:
- if isinstance(wallet_ids, str):
- wallet_ids = [wallet_ids]
-
- q = ",".join(["?"] * len(wallet_ids))
- rows = await db.fetchall(
- f"SELECT * FROM captchas WHERE wallet IN ({q})", (*wallet_ids,)
- )
-
- return [Captcha.from_row(row) for row in rows]
-
-
-async def delete_captcha(captcha_id: str) -> None:
- await db.execute("DELETE FROM captchas WHERE id = ?", (captcha_id,))
diff --git a/lnbits/extensions/captcha/migrations.py b/lnbits/extensions/captcha/migrations.py
deleted file mode 100644
index 9fe2e604..00000000
--- a/lnbits/extensions/captcha/migrations.py
+++ /dev/null
@@ -1,67 +0,0 @@
-from sqlalchemy.exc import OperationalError # type: ignore
-
-
-async def m001_initial(db):
- """
- Initial captchas table.
- """
- await db.execute(
- """
- CREATE TABLE IF NOT EXISTS captchas (
- id TEXT PRIMARY KEY,
- wallet TEXT NOT NULL,
- secret TEXT NOT NULL,
- url TEXT NOT NULL,
- memo TEXT NOT NULL,
- amount INTEGER NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
- );
- """
- )
-
-
-async def m002_redux(db):
- """
- Creates an improved captchas table and migrates the existing data.
- """
- try:
- await db.execute("SELECT remembers FROM captchas")
-
- except OperationalError:
- await db.execute("ALTER TABLE captchas RENAME TO captchas_old")
- await db.execute(
- """
- CREATE TABLE IF NOT EXISTS captchas (
- id TEXT PRIMARY KEY,
- wallet TEXT NOT NULL,
- url TEXT NOT NULL,
- memo TEXT NOT NULL,
- description TEXT NULL,
- amount INTEGER DEFAULT 0,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
- remembers INTEGER DEFAULT 0,
- extras TEXT NULL
- );
- """
- )
- await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON captchas (wallet)")
-
- for row in [
- list(row) for row in await db.fetchall("SELECT * FROM captchas_old")
- ]:
- await db.execute(
- """
- INSERT INTO captchas (
- id,
- wallet,
- url,
- memo,
- amount,
- time
- )
- VALUES (?, ?, ?, ?, ?, ?)
- """,
- (row[0], row[1], row[3], row[4], row[5], row[6]),
- )
-
- await db.execute("DROP TABLE captchas_old")
diff --git a/lnbits/extensions/captcha/models.py b/lnbits/extensions/captcha/models.py
deleted file mode 100644
index 3179d5c1..00000000
--- a/lnbits/extensions/captcha/models.py
+++ /dev/null
@@ -1,23 +0,0 @@
-import json
-
-from sqlite3 import Row
-from typing import NamedTuple, Optional
-
-
-class Captcha(NamedTuple):
- id: str
- wallet: str
- url: str
- memo: str
- description: str
- amount: int
- time: int
- remembers: bool
- extras: Optional[dict]
-
- @classmethod
- def from_row(cls, row: Row) -> "Captcha":
- data = dict(row)
- data["remembers"] = bool(data["remembers"])
- data["extras"] = json.loads(data["extras"]) if data["extras"] else None
- return cls(**data)
diff --git a/lnbits/extensions/captcha/static/js/captcha.js b/lnbits/extensions/captcha/static/js/captcha.js
deleted file mode 100644
index 1da24f57..00000000
--- a/lnbits/extensions/captcha/static/js/captcha.js
+++ /dev/null
@@ -1,82 +0,0 @@
-var ciframeLoaded = !1,
- captchaStyleAdded = !1
-
-function ccreateIframeElement(t = {}) {
- const e = document.createElement('iframe')
- // e.style.marginLeft = "25px",
- ;(e.style.border = 'none'),
- (e.style.width = '100%'),
- (e.style.height = '100%'),
- (e.scrolling = 'no'),
- (e.id = 'captcha-iframe')
- t.dest, t.amount, t.currency, t.label, t.opReturn
- var captchaid = document
- .getElementById('captchascript')
- .getAttribute('data-captchaid')
- var lnbhostsrc = document.getElementById('captchascript').getAttribute('src')
- var lnbhost = lnbhostsrc.split('/captcha/static/js/captcha.js')[0]
- return (e.src = lnbhost + '/captcha/' + captchaid), e
-}
-document.addEventListener('DOMContentLoaded', function () {
- if (captchaStyleAdded) console.log('Captcha already added!')
- else {
- console.log('Adding captcha'), (captchaStyleAdded = !0)
- var t = document.createElement('style')
- t.innerHTML =
- "\t/*Button*/\t\t.button-captcha-filled\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #FF7979;\t\t\tbox-shadow: 0 2px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\tbox-shadow: 0 0 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #FF7979;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.button-captcha-filled-dark\t\t\t{\t\t\tdisplay: flex;\t\t\talign-items: center;\t\t\tjustify-content: center;\t\t\twidth: 120px;\t\t\tmin-width: 30px;\t\t\theight: 40px;\t\t\tline-height: 2.5;\t\t\ttext-align: center;\t\t\tcursor: pointer;\t\t\t/* Rectangle 2: */\t\t\tbackground: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t\tborder-radius: 20px;\t\t\t/* Sign up: */\t\t\tfont-family: 'Avenir-Heavy', Futura, Helvetica, Arial;\t\t\tfont-size: 16px;\t\t\tcolor: #FFFFFF;\t\t}\t\t.button-captcha-filled-dark:hover\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\tbox-shadow: 0 0px 4px 0 rgba(0,0,0,0.20);\t\t}\t\t.button-captcha-filled-dark:active\t\t{\t\t\tbackground:#FFFFFF;\t\t\tcolor: #161C38;\t\t\t/*Move it down a little bit*/\t\t\tposition: relative;\t\t\ttop: 1px;\t\t}\t\t.modal-captcha-container {\t\t position: fixed;\t\t z-index: 1000;\t\t text-align: left;/*Si no añado esto, a veces hereda el text-align:center del body, y entonces el popup queda movido a la derecha, por center + margin left que aplico*/\t\t left: 0;\t\t top: 0;\t\t width: 100%;\t\t height: 100%;\t\t background-color: rgba(0, 0, 0, 0.5);\t\t opacity: 0;\t\t visibility: hidden;\t\t transform: scale(1.1);\t\t transition: visibility 0s linear 0.25s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t.modal-captcha-content {\t\t position: absolute;\t\t top: 50%;\t\t left: 50%;\t\t transform: translate(-50%, -50%);\t\t background-color: white;\t\t width: 100%;\t\t height: 100%;\t\t border-radius: 0.5rem;\t\t /*Rounded shadowed borders*/\t\t\tbox-shadow: 2px 2px 4px 0 rgba(0,0,0,0.15);\t\t\tborder-radius: 5px;\t\t}\t\t.close-button-captcha {\t\t float: right;\t\t width: 1.5rem;\t\t line-height: 1.5rem;\t\t text-align: center;\t\t cursor: pointer;\t\t margin-right:20px;\t\t margin-top:10px;\t\t border-radius: 0.25rem;\t\t background-color: lightgray;\t\t}\t\t.close-button-captcha:hover {\t\t background-color: darkgray;\t\t}\t\t.show-modal-captcha {\t\t opacity: 1;\t\t visibility: visible;\t\t transform: scale(1.0);\t\t transition: visibility 0s linear 0s, opacity 0.25s 0s, transform 0.25s;\t\t}\t\t/* Mobile */\t\t@media screen and (min-device-width: 160px) and ( max-width: 1077px ) /*No tendria ni por que poner un min-device, porq abarca todo lo humano...*/\t\t{\t\t}"
- var e = document.querySelector('script')
- e.parentNode.insertBefore(t, e)
- var i = document.getElementById('captchacheckbox'),
- n = i.dataset,
- o = 'true' === n.dark
- var a = document.createElement('div')
- ;(a.className += ' modal-captcha-container'),
- (a.innerHTML =
- '\t\t \t\t\t\t'),
- document.getElementsByTagName('body')[0].appendChild(a)
- var r = document.getElementsByClassName('modal-captcha-content').item(0)
- document
- .getElementsByClassName('close-button-captcha')
- .item(0)
- .addEventListener('click', d),
- window.addEventListener('click', function (t) {
- t.target === a && d()
- }),
- i.addEventListener('change', function () {
- if (this.checked) {
- // console.log("checkbox checked");
- if (0 == ciframeLoaded) {
- // console.log("n: ", n);
- var t = ccreateIframeElement(n)
- r.appendChild(t), (ciframeLoaded = !0)
- }
- d()
- }
- })
- }
-
- function d() {
- a.classList.toggle('show-modal-captcha')
- }
-})
-
-function receiveMessage(event) {
- if (event.data.includes('paymenthash')) {
- // console.log("paymenthash received: ", event.data);
- document.getElementById('captchapayhash').value = event.data.split('_')[1]
- }
- if (event.data.includes('removetheiframe')) {
- if (event.data.includes('nok')) {
- //invoice was NOT paid
- // console.log("receiveMessage not paid")
- document.getElementById('captchacheckbox').checked = false
- }
- ciframeLoaded = !1
- var element = document.getElementById('captcha-iframe')
- document
- .getElementsByClassName('modal-captcha-container')[0]
- .classList.toggle('show-modal-captcha')
- element.parentNode.removeChild(element)
- }
-}
-window.addEventListener('message', receiveMessage, false)
diff --git a/lnbits/extensions/captcha/templates/captcha/_api_docs.html b/lnbits/extensions/captcha/templates/captcha/_api_docs.html
deleted file mode 100644
index dfe2f32f..00000000
--- a/lnbits/extensions/captcha/templates/captcha/_api_docs.html
+++ /dev/null
@@ -1,147 +0,0 @@
-
-
-
-
- GET /captcha/api/v1/captchas
- Headers
- {"X-Api-Key": <invoice_key>}
- Body (application/json)
-
- Returns 200 OK (application/json)
-
- [<captcha_object>, ...]
- Curl example
- curl -X GET {{ request.url_root }}captcha/api/v1/captchas -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
-
-
-
-
-
-
-
- POST /captcha/api/v1/captchas
- Headers
- {"X-Api-Key": <admin_key>}
- Body (application/json)
- {"amount": <integer>, "description": <string>, "memo":
- <string>, "remembers": <boolean>, "url":
- <string>}
-
- Returns 201 CREATED (application/json)
-
- {"amount": <integer>, "description": <string>, "id":
- <string>, "memo": <string>, "remembers": <boolean>,
- "time": <int>, "url": <string>, "wallet":
- <string>}
- Curl example
- curl -X POST {{ request.url_root }}captcha/api/v1/captchas -d
- '{"url": <string>, "memo": <string>, "description":
- <string>, "amount": <integer>, "remembers":
- <boolean>}' -H "Content-type: application/json" -H "X-Api-Key:
- {{ g.user.wallets[0].adminkey }}"
-
-
-
-
-
-
-
- POST
- /captcha/api/v1/captchas/<captcha_id>/invoice
- Body (application/json)
- {"amount": <integer>}
-
- Returns 201 CREATED (application/json)
-
- {"payment_hash": <string>, "payment_request":
- <string>}
- Curl example
- curl -X POST {{ request.url_root
- }}captcha/api/v1/captchas/<captcha_id>/invoice -d '{"amount":
- <integer>}' -H "Content-type: application/json"
-
-
-
-
-
-
-
- POST
- /captcha/api/v1/captchas/<captcha_id>/check_invoice
- Body (application/json)
- {"payment_hash": <string>}
-
- Returns 200 OK (application/json)
-
- {"paid": false}
- {"paid": true, "url": <string>, "remembers":
- <boolean>}
- Curl example
- curl -X POST {{ request.url_root
- }}captcha/api/v1/captchas/<captcha_id>/check_invoice -d
- '{"payment_hash": <string>}' -H "Content-type: application/json"
-
-
-
-
-
-
-
- DELETE
- /captcha/api/v1/captchas/<captcha_id>
- Headers
- {"X-Api-Key": <admin_key>}
- Returns 204 NO CONTENT
-
- Curl example
- curl -X DELETE {{ request.url_root
- }}captcha/api/v1/captchas/<captcha_id> -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
-
-
-
-
-
diff --git a/lnbits/extensions/captcha/templates/captcha/display.html b/lnbits/extensions/captcha/templates/captcha/display.html
deleted file mode 100644
index 80e59e63..00000000
--- a/lnbits/extensions/captcha/templates/captcha/display.html
+++ /dev/null
@@ -1,178 +0,0 @@
-{% extends "public.html" %} {% block page %}
-
-
-
-
- {{ captcha.memo }}
- {% if captcha.description %}
- {{ captcha.description }}
- {% endif %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Copy invoice
- Cancel
-
-
-
-
-
-
- Captcha accepted. You are probably human.
-
-
-
-
-
-
-
-
-{% endblock %} {% block scripts %}
-
-{% endblock %}
diff --git a/lnbits/extensions/captcha/templates/captcha/index.html b/lnbits/extensions/captcha/templates/captcha/index.html
deleted file mode 100644
index 2250bced..00000000
--- a/lnbits/extensions/captcha/templates/captcha/index.html
+++ /dev/null
@@ -1,425 +0,0 @@
-{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
-%} {% block page %}
-
-
-
-
- New captcha
-
-
-
-
-
-
-
- Captchas
-
-
- Export to CSV
-
-
-
- {% raw %}
-
-
-
-
- {{ col.label }}
-
-
-
-
-
-
-
-
-
-
-
- {{ col.value }}
-
-
-
-
-
-
- {% endraw %}
-
-
-
-
-
-
-
-
- LNbits captcha extension
-
-
-
- {% include "captcha/_api_docs.html" %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Remember payments
- A succesful payment will be registered in the browser's
- storage, so the user doesn't need to pay again to prove they are
- human.
-
-
-
-
- Create captcha
- Cancel
-
-
-
-
-
-
-
- {% raw %}
-
-
-
- {{ qrCodeDialog.data.snippet }}
-
-
- Copy the snippet above and paste into your website/form. The checkbox
- can be in checked state only after user pays.
-
-
-
- ID: {{ qrCodeDialog.data.id }}
- Amount: {{ qrCodeDialog.data.amount }}
-
-
- {% endraw %}
-
- Copy Snippet
-
- Close
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }}
-
-{% endblock %}
diff --git a/lnbits/extensions/captcha/views.py b/lnbits/extensions/captcha/views.py
deleted file mode 100644
index 2b3643fa..00000000
--- a/lnbits/extensions/captcha/views.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from quart import g, abort, render_template
-from http import HTTPStatus
-
-from lnbits.decorators import check_user_exists, validate_uuids
-
-from . import captcha_ext
-from .crud import get_captcha
-
-
-@captcha_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("captcha/index.html", user=g.user)
-
-
-@captcha_ext.route("/")
-async def display(captcha_id):
- captcha = await get_captcha(captcha_id) or abort(
- HTTPStatus.NOT_FOUND, "captcha does not exist."
- )
- return await render_template("captcha/display.html", captcha=captcha)
diff --git a/lnbits/extensions/captcha/views_api.py b/lnbits/extensions/captcha/views_api.py
deleted file mode 100644
index c1b5ade8..00000000
--- a/lnbits/extensions/captcha/views_api.py
+++ /dev/null
@@ -1,121 +0,0 @@
-from quart import g, jsonify, request
-from http import HTTPStatus
-
-from lnbits.core.crud import get_user, get_wallet
-from lnbits.core.services import create_invoice, check_invoice_status
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
-
-from . import captcha_ext
-from .crud import create_captcha, get_captcha, get_captchas, delete_captcha
-
-
-@captcha_ext.route("/api/v1/captchas", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_captchas():
- wallet_ids = [g.wallet.id]
-
- if "all_wallets" in request.args:
- wallet_ids = (await get_user(g.wallet.user)).wallet_ids
-
- return (
- jsonify([captcha._asdict() for captcha in await get_captchas(wallet_ids)]),
- HTTPStatus.OK,
- )
-
-
-@captcha_ext.route("/api/v1/captchas", methods=["POST"])
-@api_check_wallet_key("invoice")
-@api_validate_post_request(
- schema={
- "url": {"type": "string", "empty": False, "required": True},
- "memo": {"type": "string", "empty": False, "required": True},
- "description": {
- "type": "string",
- "empty": True,
- "nullable": True,
- "required": False,
- },
- "amount": {"type": "integer", "min": 0, "required": True},
- "remembers": {"type": "boolean", "required": True},
- }
-)
-async def api_captcha_create():
- captcha = await create_captcha(wallet_id=g.wallet.id, **g.data)
- return jsonify(captcha._asdict()), HTTPStatus.CREATED
-
-
-@captcha_ext.route("/api/v1/captchas/", methods=["DELETE"])
-@api_check_wallet_key("invoice")
-async def api_captcha_delete(captcha_id):
- captcha = await get_captcha(captcha_id)
-
- if not captcha:
- return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND
-
- if captcha.wallet != g.wallet.id:
- return jsonify({"message": "Not your captcha."}), HTTPStatus.FORBIDDEN
-
- await delete_captcha(captcha_id)
-
- return "", HTTPStatus.NO_CONTENT
-
-
-@captcha_ext.route("/api/v1/captchas//invoice", methods=["POST"])
-@api_validate_post_request(
- schema={"amount": {"type": "integer", "min": 1, "required": True}}
-)
-async def api_captcha_create_invoice(captcha_id):
- captcha = await get_captcha(captcha_id)
-
- if g.data["amount"] < captcha.amount:
- return (
- jsonify({"message": f"Minimum amount is {captcha.amount} sat."}),
- HTTPStatus.BAD_REQUEST,
- )
-
- try:
- amount = (
- g.data["amount"] if g.data["amount"] > captcha.amount else captcha.amount
- )
- payment_hash, payment_request = await create_invoice(
- wallet_id=captcha.wallet,
- amount=amount,
- memo=f"{captcha.memo}",
- extra={"tag": "captcha"},
- )
- except Exception as e:
- return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
-
- return (
- jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
- HTTPStatus.CREATED,
- )
-
-
-@captcha_ext.route("/api/v1/captchas//check_invoice", methods=["POST"])
-@api_validate_post_request(
- schema={"payment_hash": {"type": "string", "empty": False, "required": True}}
-)
-async def api_paywal_check_invoice(captcha_id):
- captcha = await get_captcha(captcha_id)
-
- if not captcha:
- return jsonify({"message": "captcha does not exist."}), HTTPStatus.NOT_FOUND
-
- try:
- status = await check_invoice_status(captcha.wallet, g.data["payment_hash"])
- is_paid = not status.pending
- except Exception:
- return jsonify({"paid": False}), HTTPStatus.OK
-
- if is_paid:
- wallet = await get_wallet(captcha.wallet)
- payment = await wallet.get_payment(g.data["payment_hash"])
- await payment.set_pending(False)
-
- return (
- jsonify({"paid": True, "url": captcha.url, "remembers": captcha.remembers}),
- HTTPStatus.OK,
- )
-
- return jsonify({"paid": False}), HTTPStatus.OK
diff --git a/lnbits/extensions/copilot/README.md b/lnbits/extensions/copilot/README.md
new file mode 100644
index 00000000..323aeddc
--- /dev/null
+++ b/lnbits/extensions/copilot/README.md
@@ -0,0 +1,3 @@
+# StreamerCopilot
+
+Tool to help streamers accept sats for tips
diff --git a/lnbits/extensions/copilot/__init__.py b/lnbits/extensions/copilot/__init__.py
new file mode 100644
index 00000000..8a634267
--- /dev/null
+++ b/lnbits/extensions/copilot/__init__.py
@@ -0,0 +1,34 @@
+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_copilot")
+
+copilot_static_files = [
+ {
+ "path": "/copilot/static",
+ "app": StaticFiles(directory="lnbits/extensions/copilot/static"),
+ "name": "copilot_static",
+ }
+]
+copilot_ext: APIRouter = APIRouter(prefix="/copilot", tags=["copilot"])
+
+
+def copilot_renderer():
+ return template_renderer(["lnbits/extensions/copilot/templates"])
+
+
+from .lnurl import * # noqa
+from .tasks import wait_for_paid_invoices
+from .views import * # noqa
+from .views_api import * # noqa
+
+
+def copilot_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/copilot/config.json b/lnbits/extensions/copilot/config.json
new file mode 100644
index 00000000..a4ecb3b5
--- /dev/null
+++ b/lnbits/extensions/copilot/config.json
@@ -0,0 +1,8 @@
+{
+ "name": "Streamer Copilot",
+ "short_description": "Video tips/animations/webhooks",
+ "icon": "face",
+ "contributors": [
+ "arcbtc"
+ ]
+}
diff --git a/lnbits/extensions/copilot/crud.py b/lnbits/extensions/copilot/crud.py
new file mode 100644
index 00000000..d0da044e
--- /dev/null
+++ b/lnbits/extensions/copilot/crud.py
@@ -0,0 +1,97 @@
+from typing import List, Optional, Union
+
+from lnbits.helpers import urlsafe_short_hash
+
+from . import db
+from .models import Copilots, CreateCopilotData
+
+###############COPILOTS##########################
+
+
+async def create_copilot(
+ data: CreateCopilotData, inkey: Optional[str] = ""
+) -> Copilots:
+ copilot_id = urlsafe_short_hash()
+ await db.execute(
+ """
+ INSERT INTO copilot.newer_copilots (
+ id,
+ "user",
+ lnurl_toggle,
+ wallet,
+ title,
+ animation1,
+ animation2,
+ animation3,
+ animation1threshold,
+ animation2threshold,
+ animation3threshold,
+ animation1webhook,
+ animation2webhook,
+ animation3webhook,
+ lnurl_title,
+ show_message,
+ show_ack,
+ show_price,
+ fullscreen_cam,
+ iframe_url,
+ amount_made
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ copilot_id,
+ data.user,
+ int(data.lnurl_toggle),
+ data.wallet,
+ data.title,
+ data.animation1,
+ data.animation2,
+ data.animation3,
+ data.animation1threshold,
+ data.animation2threshold,
+ data.animation3threshold,
+ data.animation1webhook,
+ data.animation2webhook,
+ data.animation3webhook,
+ data.lnurl_title,
+ int(data.show_message),
+ int(data.show_ack),
+ data.show_price,
+ 0,
+ None,
+ 0,
+ ),
+ )
+ return await get_copilot(copilot_id)
+
+
+async def update_copilot(
+ data: CreateCopilotData, copilot_id: Optional[str] = ""
+) -> Optional[Copilots]:
+ q = ", ".join([f"{field[0]} = ?" for field in data])
+ items = [f"{field[1]}" for field in data]
+ items.append(copilot_id)
+ await db.execute(f"UPDATE copilot.newer_copilots SET {q} WHERE id = ?", (items))
+ row = await db.fetchone(
+ "SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
+ )
+ return Copilots(**row) if row else None
+
+
+async def get_copilot(copilot_id: str) -> Copilots:
+ row = await db.fetchone(
+ "SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
+ )
+ return Copilots(**row) if row else None
+
+
+async def get_copilots(user: str) -> List[Copilots]:
+ rows = await db.fetchall(
+ 'SELECT * FROM copilot.newer_copilots WHERE "user" = ?', (user,)
+ )
+ return [Copilots(**row) for row in rows]
+
+
+async def delete_copilot(copilot_id: str) -> None:
+ await db.execute("DELETE FROM copilot.newer_copilots WHERE id = ?", (copilot_id,))
diff --git a/lnbits/extensions/copilot/lnurl.py b/lnbits/extensions/copilot/lnurl.py
new file mode 100644
index 00000000..69777c9f
--- /dev/null
+++ b/lnbits/extensions/copilot/lnurl.py
@@ -0,0 +1,84 @@
+import hashlib
+import json
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.param_functions import Query
+from lnurl.types import LnurlPayMetadata
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse # type: ignore
+
+from lnbits.core.services import create_invoice
+
+from . import copilot_ext
+from .crud import get_copilot
+
+
+@copilot_ext.get(
+ "/lnurl/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_response"
+)
+async def lnurl_response(req: Request, cp_id: str = Query(None)):
+ cp = await get_copilot(cp_id)
+ if not cp:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
+ )
+
+ payResponse = {
+ "tag": "payRequest",
+ "callback": req.url_for("copilot.lnurl_callback", cp_id=cp_id),
+ "metadata": LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])),
+ "maxSendable": 50000000,
+ "minSendable": 10000,
+ }
+
+ if cp.show_message:
+ payResponse["commentAllowed"] = 300
+ return json.dumps(payResponse)
+
+
+@copilot_ext.get(
+ "/lnurl/cb/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_callback"
+)
+async def lnurl_callback(
+ cp_id: str = Query(None), amount: str = Query(None), comment: str = Query(None)
+):
+ cp = await get_copilot(cp_id)
+ if not cp:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
+ )
+ amount_received = int(amount)
+
+ if amount_received < 10000:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Amount {round(amount_received / 1000)} is smaller than minimum 10 sats.",
+ )
+ elif amount_received / 1000 > 10000000:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Amount {round(amount_received / 1000)} is greater than maximum 50000.",
+ )
+ comment = ""
+ if comment:
+ if len(comment or "") > 300:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Got a comment with {len(comment)} characters, but can only accept 300",
+ )
+ if len(comment) < 1:
+ comment = "none"
+ _, payment_request = await create_invoice(
+ wallet_id=cp.wallet,
+ amount=int(amount_received / 1000),
+ memo=cp.lnurl_title,
+ description_hash=hashlib.sha256(
+ (
+ LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
+ ).encode("utf-8")
+ ).digest(),
+ extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
+ )
+ payResponse = {"pr": payment_request, "routes": []}
+ return json.dumps(payResponse)
diff --git a/lnbits/extensions/copilot/migrations.py b/lnbits/extensions/copilot/migrations.py
new file mode 100644
index 00000000..b1c16dcc
--- /dev/null
+++ b/lnbits/extensions/copilot/migrations.py
@@ -0,0 +1,79 @@
+async def m001_initial(db):
+ """
+ Initial copilot table.
+ """
+
+ await db.execute(
+ f"""
+ CREATE TABLE copilot.copilots (
+ id TEXT NOT NULL PRIMARY KEY,
+ "user" TEXT,
+ title TEXT,
+ lnurl_toggle INTEGER,
+ wallet TEXT,
+ animation1 TEXT,
+ animation2 TEXT,
+ animation3 TEXT,
+ animation1threshold INTEGER,
+ animation2threshold INTEGER,
+ animation3threshold INTEGER,
+ animation1webhook TEXT,
+ animation2webhook TEXT,
+ animation3webhook TEXT,
+ lnurl_title TEXT,
+ show_message INTEGER,
+ show_ack INTEGER,
+ show_price INTEGER,
+ amount_made INTEGER,
+ fullscreen_cam INTEGER,
+ iframe_url TEXT,
+ timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
+ );
+ """
+ )
+
+
+async def m002_fix_data_types(db):
+ """
+ Fix data types.
+ """
+
+ if db.type != "SQLITE":
+ await db.execute(
+ "ALTER TABLE copilot.copilots ALTER COLUMN show_price TYPE TEXT;"
+ )
+
+
+async def m003_fix_data_types(db):
+ await db.execute(
+ f"""
+ CREATE TABLE copilot.newer_copilots (
+ id TEXT NOT NULL PRIMARY KEY,
+ "user" TEXT,
+ title TEXT,
+ lnurl_toggle INTEGER,
+ wallet TEXT,
+ animation1 TEXT,
+ animation2 TEXT,
+ animation3 TEXT,
+ animation1threshold INTEGER,
+ animation2threshold INTEGER,
+ animation3threshold INTEGER,
+ animation1webhook TEXT,
+ animation2webhook TEXT,
+ animation3webhook TEXT,
+ lnurl_title TEXT,
+ show_message INTEGER,
+ show_ack INTEGER,
+ show_price TEXT,
+ amount_made INTEGER,
+ fullscreen_cam INTEGER,
+ iframe_url TEXT,
+ timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
+ );
+ """
+ )
+
+ await db.execute(
+ "INSERT INTO copilot.newer_copilots SELECT * FROM copilot.copilots"
+ )
diff --git a/lnbits/extensions/copilot/models.py b/lnbits/extensions/copilot/models.py
new file mode 100644
index 00000000..a279879d
--- /dev/null
+++ b/lnbits/extensions/copilot/models.py
@@ -0,0 +1,64 @@
+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 LnurlPayMetadata # type: ignore
+from pydantic import BaseModel
+import json
+from sqlite3 import Row
+
+
+class CreateCopilotData(BaseModel):
+ user: str = Query(None)
+ title: str = Query(None)
+ lnurl_toggle: int = Query(0)
+ wallet: str = Query(None)
+ animation1: str = Query(None)
+ animation2: str = Query(None)
+ animation3: str = Query(None)
+ animation1threshold: int = Query(None)
+ animation2threshold: int = Query(None)
+ animation3threshold: int = Query(None)
+ animation1webhook: str = Query(None)
+ animation2webhook: str = Query(None)
+ animation3webhook: str = Query(None)
+ lnurl_title: str = Query(None)
+ show_message: int = Query(0)
+ show_ack: int = Query(0)
+ show_price: str = Query(None)
+ amount_made: int = Query(0)
+ timestamp: int = Query(0)
+ fullscreen_cam: int = Query(0)
+ iframe_url: str = Query(None)
+ success_url: str = Query(None)
+
+
+class Copilots(BaseModel):
+ id: str
+ user: str = Query(None)
+ title: str = Query(None)
+ lnurl_toggle: int = Query(0)
+ wallet: str = Query(None)
+ animation1: str = Query(None)
+ animation2: str = Query(None)
+ animation3: str = Query(None)
+ animation1threshold: int = Query(None)
+ animation2threshold: int = Query(None)
+ animation3threshold: int = Query(None)
+ animation1webhook: str = Query(None)
+ animation2webhook: str = Query(None)
+ animation3webhook: str = Query(None)
+ lnurl_title: str = Query(None)
+ show_message: int = Query(0)
+ show_ack: int = Query(0)
+ show_price: str = Query(None)
+ amount_made: int = Query(0)
+ timestamp: int = Query(0)
+ fullscreen_cam: int = Query(0)
+ iframe_url: str = Query(None)
+ success_url: str = Query(None)
+
+ def lnurl(self, req: Request) -> str:
+ url = req.url_for("copilot.lnurl_response", cp_id=self.id)
+ return lnurl_encode(url)
diff --git a/lnbits/extensions/copilot/static/bitcoin.gif b/lnbits/extensions/copilot/static/bitcoin.gif
new file mode 100644
index 00000000..ef8c2ecd
Binary files /dev/null and b/lnbits/extensions/copilot/static/bitcoin.gif differ
diff --git a/lnbits/extensions/copilot/static/confetti.gif b/lnbits/extensions/copilot/static/confetti.gif
new file mode 100644
index 00000000..a3fec971
Binary files /dev/null and b/lnbits/extensions/copilot/static/confetti.gif differ
diff --git a/lnbits/extensions/copilot/static/face.gif b/lnbits/extensions/copilot/static/face.gif
new file mode 100644
index 00000000..3e70d779
Binary files /dev/null and b/lnbits/extensions/copilot/static/face.gif differ
diff --git a/lnbits/extensions/copilot/static/lnurl.png b/lnbits/extensions/copilot/static/lnurl.png
new file mode 100644
index 00000000..ad2c9715
Binary files /dev/null and b/lnbits/extensions/copilot/static/lnurl.png differ
diff --git a/lnbits/extensions/copilot/static/martijn.gif b/lnbits/extensions/copilot/static/martijn.gif
new file mode 100644
index 00000000..e410677d
Binary files /dev/null and b/lnbits/extensions/copilot/static/martijn.gif differ
diff --git a/lnbits/extensions/copilot/static/rick.gif b/lnbits/extensions/copilot/static/rick.gif
new file mode 100644
index 00000000..c36c7e19
Binary files /dev/null and b/lnbits/extensions/copilot/static/rick.gif differ
diff --git a/lnbits/extensions/copilot/static/rocket.gif b/lnbits/extensions/copilot/static/rocket.gif
new file mode 100644
index 00000000..6f19597d
Binary files /dev/null and b/lnbits/extensions/copilot/static/rocket.gif differ
diff --git a/lnbits/extensions/copilot/tasks.py b/lnbits/extensions/copilot/tasks.py
new file mode 100644
index 00000000..351eb24b
--- /dev/null
+++ b/lnbits/extensions/copilot/tasks.py
@@ -0,0 +1,81 @@
+import asyncio
+import json
+from http import HTTPStatus
+
+import httpx
+from starlette.exceptions import HTTPException
+
+from lnbits.core import db as core_db
+from lnbits.core.models import Payment
+from lnbits.tasks import register_invoice_listener
+
+from .crud import get_copilot
+from .views import updater
+
+
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
+
+ while True:
+ payment = await invoice_queue.get()
+ await on_invoice_paid(payment)
+
+
+async def on_invoice_paid(payment: Payment) -> None:
+ webhook = None
+ data = None
+ if "copilot" != payment.extra.get("tag"):
+ # not an copilot invoice
+ return
+
+ copilot = await get_copilot(payment.extra.get("copilotid", -1))
+
+ if not copilot:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
+ )
+ if copilot.animation1threshold:
+ if int(payment.amount / 1000) >= copilot.animation1threshold:
+ data = copilot.animation1
+ webhook = copilot.animation1webhook
+ if copilot.animation2threshold:
+ if int(payment.amount / 1000) >= copilot.animation2threshold:
+ data = copilot.animation2
+ webhook = copilot.animation1webhook
+ if copilot.animation3threshold:
+ if int(payment.amount / 1000) >= copilot.animation3threshold:
+ data = copilot.animation3
+ webhook = copilot.animation1webhook
+ if webhook:
+ async with httpx.AsyncClient() as client:
+ try:
+ r = await client.post(
+ webhook,
+ json={
+ "payment_hash": payment.payment_hash,
+ "payment_request": payment.bolt11,
+ "amount": payment.amount,
+ "comment": payment.extra.get("comment"),
+ },
+ timeout=40,
+ )
+ await mark_webhook_sent(payment, r.status_code)
+ except (httpx.ConnectError, httpx.RequestError):
+ await mark_webhook_sent(payment, -1)
+ if payment.extra.get("comment"):
+ await updater(copilot.id, data, payment.extra.get("comment"))
+
+ await updater(copilot.id, data, "none")
+
+
+async def mark_webhook_sent(payment: Payment, 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),
+ )
diff --git a/lnbits/extensions/copilot/templates/copilot/_api_docs.html b/lnbits/extensions/copilot/templates/copilot/_api_docs.html
new file mode 100644
index 00000000..64acba14
--- /dev/null
+++ b/lnbits/extensions/copilot/templates/copilot/_api_docs.html
@@ -0,0 +1,172 @@
+
+
+
+ StreamerCopilot: get tips via static QR (lnurl-pay) and show an
+ animation
+
+ Created by, Ben Arc
+
+
+
+
+
+
+ POST /copilot/api/v1/copilot
+ Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<copilot_object>, ...]
+ Curl example
+ curl -X POST {{ request.base_url }}api/v1/copilot -d '{"title":
+ <string>, "animation": <string>,
+ "show_message":<string>, "amount": <integer>,
+ "lnurl_title": <string>}' -H "Content-type: application/json"
+ -H "X-Api-Key: {{user.wallets[0].adminkey }}"
+
+
+
+
+
+
+
+ PUT
+ /copilot/api/v1/copilot/<copilot_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<copilot_object>, ...]
+ Curl example
+ curl -X POST {{ request.base_url
+ }}api/v1/copilot/<copilot_id> -d '{"title": <string>,
+ "animation": <string>, "show_message":<string>,
+ "amount": <integer>, "lnurl_title": <string>}' -H
+ "Content-type: application/json" -H "X-Api-Key:
+ {{user.wallets[0].adminkey }}"
+
+
+
+
+
+
+
+
+ GET
+ /copilot/api/v1/copilot/<copilot_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<copilot_object>, ...]
+ Curl example
+ curl -X GET {{ request.base_url }}api/v1/copilot/<copilot_id>
+ -H "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ GET /copilot/api/v1/copilots
+ Headers
+ {"X-Api-Key": <invoice_key>}
+
+ Body (application/json)
+
+
+ Returns 200 OK (application/json)
+
+ [<copilot_object>, ...]
+ Curl example
+ curl -X GET {{ request.base_url }}api/v1/copilots -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ DELETE
+ /copilot/api/v1/copilot/<copilot_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Returns 204 NO CONTENT
+
+ Curl example
+ curl -X DELETE {{ request.base_url
+ }}api/v1/copilot/<copilot_id> -H "X-Api-Key: {{
+ user.wallets[0].adminkey }}"
+
+
+
+
+
+
+
+ GET
+ /api/v1/copilot/ws/<copilot_id>/<comment>/<data>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Returns 200
+
+ Curl example
+ curl -X GET {{ request.base_url }}/api/v1/copilot/ws/<string,
+ copilot_id>/<string, comment>/<string, gif name> -H
+ "X-Api-Key: {{ user.wallets[0].adminkey }}"
+
+
+
+
+
+
diff --git a/lnbits/extensions/copilot/templates/copilot/compose.html b/lnbits/extensions/copilot/templates/copilot/compose.html
new file mode 100644
index 00000000..b4022ee0
--- /dev/null
+++ b/lnbits/extensions/copilot/templates/copilot/compose.html
@@ -0,0 +1,287 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+
+
+
+
+ {% raw %}{{ copilot.lnurl_title }}{% endraw %}
+
+
+
+
+
+ {% raw %}{{ price }}{% endraw %}
+
+
+ Powered by LNbits/StreamerCopilot
+
+
+{% endblock %} {% block scripts %}
+
+
+{% endblock %}
diff --git a/lnbits/extensions/copilot/templates/copilot/index.html b/lnbits/extensions/copilot/templates/copilot/index.html
new file mode 100644
index 00000000..95c08bae
--- /dev/null
+++ b/lnbits/extensions/copilot/templates/copilot/index.html
@@ -0,0 +1,660 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+
+ {% raw %}
+ New copilot instance
+
+
+
+
+
+
+
+
+ Copilots
+
+
+
+
+
+
+
+
+ Export to CSV
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+ Panel
+
+
+
+
+ Compose window
+
+
+
+
+ Delete copilot
+
+
+
+
+ Edit copilot
+
+
+
+
+ {{ col.value }}
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} StreamCopilot Extension
+
+
+
+
+ {% include "copilot/_api_docs.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Update Copilot
+ Create Copilot
+ Cancel
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/copilot/templates/copilot/panel.html b/lnbits/extensions/copilot/templates/copilot/panel.html
new file mode 100644
index 00000000..f17bf34c
--- /dev/null
+++ b/lnbits/extensions/copilot/templates/copilot/panel.html
@@ -0,0 +1,156 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+
+
+
+
+
+
+ Title: {% raw %} {{ copilot.title }} {% endraw %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
diff --git a/lnbits/extensions/copilot/views.py b/lnbits/extensions/copilot/views.py
new file mode 100644
index 00000000..ff0a5a93
--- /dev/null
+++ b/lnbits/extensions/copilot/views.py
@@ -0,0 +1,80 @@
+from typing import List
+
+from fastapi import Request, WebSocket, WebSocketDisconnect
+from fastapi.params import Depends
+from fastapi.templating import Jinja2Templates
+from starlette.responses import HTMLResponse # type: ignore
+
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import copilot_ext, copilot_renderer
+from .crud import get_copilot
+
+templates = Jinja2Templates(directory="templates")
+
+
+@copilot_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return copilot_renderer().TemplateResponse(
+ "copilot/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@copilot_ext.get("/cp/", response_class=HTMLResponse)
+async def compose(request: Request):
+ return copilot_renderer().TemplateResponse(
+ "copilot/compose.html", {"request": request}
+ )
+
+
+@copilot_ext.get("/pn/", response_class=HTMLResponse)
+async def panel(request: Request):
+ return copilot_renderer().TemplateResponse(
+ "copilot/panel.html", {"request": request}
+ )
+
+
+##################WEBSOCKET ROUTES########################
+
+
+class ConnectionManager:
+ def __init__(self):
+ self.active_connections: List[WebSocket] = []
+
+ async def connect(self, websocket: WebSocket, copilot_id: str):
+ await websocket.accept()
+ websocket.id = copilot_id
+ self.active_connections.append(websocket)
+
+ def disconnect(self, websocket: WebSocket):
+ self.active_connections.remove(websocket)
+
+ async def send_personal_message(self, message: str, copilot_id: str):
+ for connection in self.active_connections:
+ if connection.id == copilot_id:
+ await connection.send_text(message)
+
+ async def broadcast(self, message: str):
+ for connection in self.active_connections:
+ await connection.send_text(message)
+
+
+manager = ConnectionManager()
+
+
+@copilot_ext.websocket("/copilot/ws/{copilot_id}", name="copilot.websocket_by_id")
+async def websocket_endpoint(websocket: WebSocket, copilot_id: str):
+ await manager.connect(websocket, copilot_id)
+ try:
+ while True:
+ data = await websocket.receive_text()
+ except WebSocketDisconnect:
+ manager.disconnect(websocket)
+
+
+async def updater(copilot_id, data, comment):
+ copilot = await get_copilot(copilot_id)
+ if not copilot:
+ return
+ await manager.send_personal_message(f"{data + '-' + comment}", copilot_id)
diff --git a/lnbits/extensions/copilot/views_api.py b/lnbits/extensions/copilot/views_api.py
new file mode 100644
index 00000000..91b0572a
--- /dev/null
+++ b/lnbits/extensions/copilot/views_api.py
@@ -0,0 +1,97 @@
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.param_functions import Query
+from fastapi.params import Depends
+from starlette.exceptions import HTTPException
+
+from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
+
+from . import copilot_ext
+from .crud import (
+ create_copilot,
+ delete_copilot,
+ get_copilot,
+ get_copilots,
+ update_copilot,
+)
+from .models import CreateCopilotData
+from .views import updater
+
+#######################COPILOT##########################
+
+
+@copilot_ext.get("/api/v1/copilot")
+async def api_copilots_retrieve(
+ req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ wallet_user = wallet.wallet.user
+ copilots = [copilot.dict() for copilot in await get_copilots(wallet_user)]
+ try:
+ return copilots
+ except:
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No copilots")
+
+
+@copilot_ext.get("/api/v1/copilot/{copilot_id}")
+async def api_copilot_retrieve(
+ req: Request,
+ copilot_id: str = Query(None),
+ wallet: WalletTypeInfo = Depends(get_key_type),
+):
+ copilot = await get_copilot(copilot_id)
+ if not copilot:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
+ )
+ if not copilot.lnurl_toggle:
+ return copilot.dict()
+ return {**copilot.dict(), **{"lnurl": copilot.lnurl(req)}}
+
+
+@copilot_ext.post("/api/v1/copilot")
+@copilot_ext.put("/api/v1/copilot/{juke_id}")
+async def api_copilot_create_or_update(
+ data: CreateCopilotData,
+ copilot_id: str = Query(None),
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+):
+ data.user = wallet.wallet.user
+ data.wallet = wallet.wallet.id
+ if copilot_id:
+ copilot = await update_copilot(data, copilot_id=copilot_id)
+ else:
+ copilot = await create_copilot(data, inkey=wallet.wallet.inkey)
+ return copilot
+
+
+@copilot_ext.delete("/api/v1/copilot/{copilot_id}")
+async def api_copilot_delete(
+ copilot_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
+):
+ copilot = await get_copilot(copilot_id)
+
+ if not copilot:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
+ )
+
+ await delete_copilot(copilot_id)
+
+ return "", HTTPStatus.NO_CONTENT
+
+
+@copilot_ext.get("/api/v1/copilot/ws/{copilot_id}/{comment}/{data}")
+async def api_copilot_ws_relay(
+ copilot_id: str = Query(None), comment: str = Query(None), data: str = Query(None)
+):
+ copilot = await get_copilot(copilot_id)
+ if not copilot:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
+ )
+ try:
+ await updater(copilot_id, data, comment)
+ except:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
+ return ""
diff --git a/lnbits/extensions/diagonalley/README.md b/lnbits/extensions/diagonalley/README.md
deleted file mode 100644
index 6ba653e7..00000000
--- a/lnbits/extensions/diagonalley/README.md
+++ /dev/null
@@ -1,10 +0,0 @@
-Diagon Alley
-A movable market stand
-Make a list of products to sell, point the list to an indexer (or many), stack sats.
-Diagon Alley is a movable market stand, for anon transactions. You then give permission for an indexer to list those products. Delivery addresses are sent through the Lightning Network.
-
-
-
-API endpoints
-
-curl -X GET http://YOUR-TOR-ADDRESS
diff --git a/lnbits/extensions/diagonalley/__init__.py b/lnbits/extensions/diagonalley/__init__.py
deleted file mode 100644
index ac907f5c..00000000
--- a/lnbits/extensions/diagonalley/__init__.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from quart import Blueprint
-
-
-diagonalley_ext: Blueprint = Blueprint(
- "diagonalley", __name__, static_folder="static", template_folder="templates"
-)
-
-
-from .views_api import * # noqa
-from .views import * # noqa
diff --git a/lnbits/extensions/diagonalley/config.json.example b/lnbits/extensions/diagonalley/config.json.example
deleted file mode 100644
index 057d0f23..00000000
--- a/lnbits/extensions/diagonalley/config.json.example
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "name": "Diagon Alley",
- "short_description": "Movable anonymous market stand",
- "icon": "add_shopping_cart",
- "contributors": ["benarc"]
-}
diff --git a/lnbits/extensions/diagonalley/crud.py b/lnbits/extensions/diagonalley/crud.py
deleted file mode 100644
index 8e89c35a..00000000
--- a/lnbits/extensions/diagonalley/crud.py
+++ /dev/null
@@ -1,295 +0,0 @@
-from base64 import urlsafe_b64encode
-from uuid import uuid4
-from typing import List, Optional, Union
-import httpx
-from lnbits.db import open_ext_db
-from lnbits.settings import WALLET
-from .models import Products, Orders, Indexers
-import re
-
-regex = re.compile(
- r"^(?:http|ftp)s?://" # http:// or https://
- r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|"
- r"localhost|"
- r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"
- r"(?::\d+)?"
- r"(?:/?|[/?]\S+)$",
- re.IGNORECASE,
-)
-
-###Products
-
-
-def create_diagonalleys_product(
- *,
- wallet_id: str,
- product: str,
- categories: str,
- description: str,
- image: str,
- price: int,
- quantity: int,
-) -> Products:
- with open_ext_db("diagonalley") as db:
- product_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
- db.execute(
- """
- INSERT INTO products (id, wallet, product, categories, description, image, price, quantity)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- product_id,
- wallet_id,
- product,
- categories,
- description,
- image,
- price,
- quantity,
- ),
- )
-
- return get_diagonalleys_product(product_id)
-
-
-def update_diagonalleys_product(product_id: str, **kwargs) -> Optional[Indexers]:
- q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
-
- with open_ext_db("diagonalley") as db:
- db.execute(
- f"UPDATE products SET {q} WHERE id = ?", (*kwargs.values(), product_id)
- )
- row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
-
- return get_diagonalleys_indexer(product_id)
-
-
-def get_diagonalleys_product(product_id: str) -> Optional[Products]:
- with open_ext_db("diagonalley") as db:
- row = db.fetchone("SELECT * FROM products WHERE id = ?", (product_id,))
-
- return Products(**row) if row else None
-
-
-def get_diagonalleys_products(wallet_ids: Union[str, List[str]]) -> List[Products]:
- if isinstance(wallet_ids, str):
- wallet_ids = [wallet_ids]
-
- with open_ext_db("diagonalley") as db:
- q = ",".join(["?"] * len(wallet_ids))
- rows = db.fetchall(
- f"SELECT * FROM products WHERE wallet IN ({q})", (*wallet_ids,)
- )
-
- return [Products(**row) for row in rows]
-
-
-def delete_diagonalleys_product(product_id: str) -> None:
- with open_ext_db("diagonalley") as db:
- db.execute("DELETE FROM products WHERE id = ?", (product_id,))
-
-
-###Indexers
-
-
-def create_diagonalleys_indexer(
- wallet_id: str,
- shopname: str,
- indexeraddress: str,
- shippingzone1: str,
- shippingzone2: str,
- zone1cost: int,
- zone2cost: int,
- email: str,
-) -> Indexers:
- with open_ext_db("diagonalley") as db:
- indexer_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
- db.execute(
- """
- INSERT INTO indexers (id, wallet, shopname, indexeraddress, online, rating, shippingzone1, shippingzone2, zone1cost, zone2cost, email)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- indexer_id,
- wallet_id,
- shopname,
- indexeraddress,
- False,
- 0,
- shippingzone1,
- shippingzone2,
- zone1cost,
- zone2cost,
- email,
- ),
- )
- return get_diagonalleys_indexer(indexer_id)
-
-
-def update_diagonalleys_indexer(indexer_id: str, **kwargs) -> Optional[Indexers]:
- q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
-
- with open_ext_db("diagonalley") as db:
- db.execute(
- f"UPDATE indexers SET {q} WHERE id = ?", (*kwargs.values(), indexer_id)
- )
- row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
-
- return get_diagonalleys_indexer(indexer_id)
-
-
-def get_diagonalleys_indexer(indexer_id: str) -> Optional[Indexers]:
- with open_ext_db("diagonalley") as db:
- roww = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
- try:
- x = httpx.get(roww["indexeraddress"] + "/" + roww["ratingkey"])
- if x.status_code == 200:
- print(x)
- print("poo")
- with open_ext_db("diagonalley") as db:
- db.execute(
- "UPDATE indexers SET online = ? WHERE id = ?",
- (
- True,
- indexer_id,
- ),
- )
- else:
- with open_ext_db("diagonalley") as db:
- db.execute(
- "UPDATE indexers SET online = ? WHERE id = ?",
- (
- False,
- indexer_id,
- ),
- )
- except:
- print("An exception occurred")
- with open_ext_db("diagonalley") as db:
- row = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
- return Indexers(**row) if row else None
-
-
-def get_diagonalleys_indexers(wallet_ids: Union[str, List[str]]) -> List[Indexers]:
- if isinstance(wallet_ids, str):
- wallet_ids = [wallet_ids]
-
- with open_ext_db("diagonalley") as db:
- q = ",".join(["?"] * len(wallet_ids))
- rows = db.fetchall(
- f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
- )
-
- for r in rows:
- try:
- x = httpx.get(r["indexeraddress"] + "/" + r["ratingkey"])
- if x.status_code == 200:
- with open_ext_db("diagonalley") as db:
- db.execute(
- "UPDATE indexers SET online = ? WHERE id = ?",
- (
- True,
- r["id"],
- ),
- )
- else:
- with open_ext_db("diagonalley") as db:
- db.execute(
- "UPDATE indexers SET online = ? WHERE id = ?",
- (
- False,
- r["id"],
- ),
- )
- except:
- print("An exception occurred")
- with open_ext_db("diagonalley") as db:
- q = ",".join(["?"] * len(wallet_ids))
- rows = db.fetchall(
- f"SELECT * FROM indexers WHERE wallet IN ({q})", (*wallet_ids,)
- )
- return [Indexers(**row) for row in rows]
-
-
-def delete_diagonalleys_indexer(indexer_id: str) -> None:
- with open_ext_db("diagonalley") as db:
- db.execute("DELETE FROM indexers WHERE id = ?", (indexer_id,))
-
-
-###Orders
-
-
-def create_diagonalleys_order(
- *,
- productid: str,
- wallet: str,
- product: str,
- quantity: int,
- shippingzone: str,
- address: str,
- email: str,
- invoiceid: str,
- paid: bool,
- shipped: bool,
-) -> Indexers:
- with open_ext_db("diagonalley") as db:
- order_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
- db.execute(
- """
- INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- order_id,
- productid,
- wallet,
- product,
- quantity,
- shippingzone,
- address,
- email,
- invoiceid,
- False,
- False,
- ),
- )
-
- return get_diagonalleys_order(order_id)
-
-
-def get_diagonalleys_order(order_id: str) -> Optional[Orders]:
- with open_ext_db("diagonalley") as db:
- row = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
-
- return Orders(**row) if row else None
-
-
-def get_diagonalleys_orders(wallet_ids: Union[str, List[str]]) -> List[Orders]:
- if isinstance(wallet_ids, str):
- wallet_ids = [wallet_ids]
-
- with open_ext_db("diagonalley") as db:
- q = ",".join(["?"] * len(wallet_ids))
- rows = db.fetchall(
- f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
- )
- for r in rows:
- PAID = (await WALLET.get_invoice_status(r["invoiceid"])).paid
- if PAID:
- with open_ext_db("diagonalley") as db:
- db.execute(
- "UPDATE orders SET paid = ? WHERE id = ?",
- (
- True,
- r["id"],
- ),
- )
- rows = db.fetchall(
- f"SELECT * FROM orders WHERE wallet IN ({q})", (*wallet_ids,)
- )
- return [Orders(**row) for row in rows]
-
-
-def delete_diagonalleys_order(order_id: str) -> None:
- with open_ext_db("diagonalley") as db:
- db.execute("DELETE FROM orders WHERE id = ?", (order_id,))
diff --git a/lnbits/extensions/diagonalley/migrations.py b/lnbits/extensions/diagonalley/migrations.py
deleted file mode 100644
index a70368fc..00000000
--- a/lnbits/extensions/diagonalley/migrations.py
+++ /dev/null
@@ -1,60 +0,0 @@
-async def m001_initial(db):
- """
- Initial products table.
- """
- await db.execute(
- """
- CREATE TABLE IF NOT EXISTS products (
- id TEXT PRIMARY KEY,
- wallet TEXT NOT NULL,
- product TEXT NOT NULL,
- categories TEXT NOT NULL,
- description TEXT NOT NULL,
- image TEXT NOT NULL,
- price INTEGER NOT NULL,
- quantity INTEGER NOT NULL
- );
- """
- )
-
- """
- Initial indexers table.
- """
- await db.execute(
- """
- CREATE TABLE IF NOT EXISTS indexers (
- id TEXT PRIMARY KEY,
- wallet TEXT NOT NULL,
- shopname TEXT NOT NULL,
- indexeraddress TEXT NOT NULL,
- online BOOLEAN NOT NULL,
- rating INTEGER NOT NULL,
- shippingzone1 TEXT NOT NULL,
- shippingzone2 TEXT NOT NULL,
- zone1cost INTEGER NOT NULL,
- zone2cost INTEGER NOT NULL,
- email TEXT NOT NULL
- );
- """
- )
-
- """
- Initial orders table.
- """
- await db.execute(
- """
- CREATE TABLE IF NOT EXISTS orders (
- id TEXT PRIMARY KEY,
- productid TEXT NOT NULL,
- wallet TEXT NOT NULL,
- product TEXT NOT NULL,
- quantity INTEGER NOT NULL,
- shippingzone INTEGER NOT NULL,
- address TEXT NOT NULL,
- email TEXT NOT NULL,
- invoiceid TEXT NOT NULL,
- paid BOOLEAN NOT NULL,
- shipped BOOLEAN NOT NULL
- );
- """
- )
diff --git a/lnbits/extensions/diagonalley/models.py b/lnbits/extensions/diagonalley/models.py
deleted file mode 100644
index 08e15969..00000000
--- a/lnbits/extensions/diagonalley/models.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from typing import NamedTuple
-
-
-class Indexers(NamedTuple):
- id: str
- wallet: str
- shopname: str
- indexeraddress: str
- online: bool
- rating: str
- shippingzone1: str
- shippingzone2: str
- zone1cost: int
- zone2cost: int
- email: str
-
-
-class Products(NamedTuple):
- id: str
- wallet: str
- product: str
- categories: str
- description: str
- image: str
- price: int
- quantity: int
-
-
-class Orders(NamedTuple):
- id: str
- productid: str
- wallet: str
- product: str
- quantity: int
- shippingzone: int
- address: str
- email: str
- invoiceid: str
- paid: bool
- shipped: bool
diff --git a/lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html b/lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html
deleted file mode 100644
index 585e8d7c..00000000
--- a/lnbits/extensions/diagonalley/templates/diagonalley/_api_docs.html
+++ /dev/null
@@ -1,122 +0,0 @@
-
-
-
-
- Diagon Alley: Decentralised Market-Stalls
-
-
- Make a list of products to sell, point your list of products at a public
- indexer. Buyers browse your products on the indexer, and pay you
- directly. Ratings are managed by the indexer. Your stall can be listed
- in multiple indexers, even over TOR, if you wish to be anonymous.
- More information on the
- Diagon Alley Protocol
-
- Created by, Ben Arc
-
-
-
-
-
-
-
-
- GET
- /api/v1/diagonalley/stall/products/<indexer_id>
- Body (application/json)
-
- Returns 201 CREATED (application/json)
-
- Product JSON list
- Curl example
- curl -X GET {{ request.url_root
- }}diagonalley/api/v1/diagonalley/stall/products/<indexer_id>
-
-
-
-
-
-
- POST
- /api/v1/diagonalley/stall/order/<indexer_id>
- Body (application/json)
- {"id": <string>, "address": <string>, "shippingzone":
- <integer>, "email": <string>, "quantity":
- <integer>}
-
- Returns 201 CREATED (application/json)
-
- {"checking_id": <string>,"payment_request":
- <string>}
- Curl example
- curl -X POST {{ request.url_root
- }}diagonalley/api/v1/diagonalley/stall/order/<indexer_id> -d
- '{"id": <product_id&>, "email": <customer_email>,
- "address": <customer_address>, "quantity": 2, "shippingzone":
- 1}' -H "Content-type: application/json"
-
-
-
-
-
-
-
- GET
- /diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id>
- Headers
-
- Returns 200 OK (application/json)
-
- {"shipped": <boolean>}
- Curl example
- curl -X GET {{ request.url_root
- }}diagonalley/api/v1/diagonalley/stall/checkshipped/<checking_id>
- -H "Content-type: application/json"
-
-
-
-
diff --git a/lnbits/extensions/diagonalley/templates/diagonalley/index.html b/lnbits/extensions/diagonalley/templates/diagonalley/index.html
deleted file mode 100644
index cdf9da53..00000000
--- a/lnbits/extensions/diagonalley/templates/diagonalley/index.html
+++ /dev/null
@@ -1,906 +0,0 @@
-{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
-%} {% block page %}
-
-
-
-
- New Product
- New Indexer
-
- Frontend shop your stall will list its products in
-
-
-
-
-
-
-
-
- Products
-
-
- Export to CSV
-
-
-
- {% raw %}
-
-
-
-
- {{ col.label }}
-
-
-
-
-
-
-
-
- Link to pass to stall indexer
-
-
- {{ col.value }}
-
-
-
-
-
-
-
- {% endraw %}
-
-
-
-
-
-
-
-
- Indexers
-
-
- Export to CSV
-
-
-
- {% raw %}
-
-
-
- {{ col.label }}
-
-
-
-
-
-
-
- {{ col.value }}
-
-
-
-
-
-
-
- {% endraw %}
-
-
-
-
-
-
-
-
- Orders
-
-
- Export to CSV
-
-
-
- {% raw %}
-
-
-
- {{ col.label }}
-
-
-
-
-
-
-
- {{ col.value }}
-
-
-
-
- Product shipped?
-
-
-
-
-
-
-
- {% endraw %}
-
-
-
-
-
-
-
-
- LNbits Diagon Alley Extension
-
-
-
- {% include "diagonalley/_api_docs.html" %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Update Product
-
- Create Product
-
- Cancel
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Update Indexer
-
- Create Indexer
-
- Cancel
-
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }}
-
-{% endblock %}
diff --git a/lnbits/extensions/diagonalley/templates/diagonalley/stall.html b/lnbits/extensions/diagonalley/templates/diagonalley/stall.html
deleted file mode 100644
index a45d254d..00000000
--- a/lnbits/extensions/diagonalley/templates/diagonalley/stall.html
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/lnbits/extensions/diagonalley/views.py b/lnbits/extensions/diagonalley/views.py
deleted file mode 100644
index 6781a99e..00000000
--- a/lnbits/extensions/diagonalley/views.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from quart import g, render_template
-
-from lnbits.decorators import check_user_exists, validate_uuids
-from lnbits.extensions.diagonalley import diagonalley_ext
-
-
-@diagonalley_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("diagonalley/index.html", user=g.user)
diff --git a/lnbits/extensions/diagonalley/views_api.py b/lnbits/extensions/diagonalley/views_api.py
deleted file mode 100644
index cfb83566..00000000
--- a/lnbits/extensions/diagonalley/views_api.py
+++ /dev/null
@@ -1,352 +0,0 @@
-from quart import g, jsonify, request
-from http import HTTPStatus
-
-from lnbits.core.crud import get_user
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
-
-from lnbits.extensions.diagonalley import diagonalley_ext
-from .crud import (
- create_diagonalleys_product,
- get_diagonalleys_product,
- get_diagonalleys_products,
- delete_diagonalleys_product,
- create_diagonalleys_indexer,
- update_diagonalleys_indexer,
- get_diagonalleys_indexer,
- get_diagonalleys_indexers,
- delete_diagonalleys_indexer,
- create_diagonalleys_order,
- get_diagonalleys_order,
- get_diagonalleys_orders,
- update_diagonalleys_product,
-)
-from lnbits.core.services import create_invoice
-from base64 import urlsafe_b64encode
-from uuid import uuid4
-from lnbits.db import open_ext_db
-
-### Products
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["GET"])
-@api_check_wallet_key(key_type="invoice")
-async def api_diagonalley_products():
- wallet_ids = [g.wallet.id]
-
- if "all_wallets" in request.args:
- wallet_ids = get_user(g.wallet.user).wallet_ids
-
- return (
- jsonify(
- [product._asdict() for product in get_diagonalleys_products(wallet_ids)]
- ),
- HTTPStatus.OK,
- )
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["POST"])
-@diagonalley_ext.route("/api/v1/diagonalley/products", methods=["PUT"])
-@api_check_wallet_key(key_type="invoice")
-@api_validate_post_request(
- schema={
- "product": {"type": "string", "empty": False, "required": True},
- "categories": {"type": "string", "empty": False, "required": True},
- "description": {"type": "string", "empty": False, "required": True},
- "image": {"type": "string", "empty": False, "required": True},
- "price": {"type": "integer", "min": 0, "required": True},
- "quantity": {"type": "integer", "min": 0, "required": True},
- }
-)
-async def api_diagonalley_product_create(product_id=None):
-
- if product_id:
- product = get_diagonalleys_indexer(product_id)
-
- if not product:
- return (
- jsonify({"message": "Withdraw product does not exist."}),
- HTTPStatus.NOT_FOUND,
- )
-
- if product.wallet != g.wallet.id:
- return (
- jsonify({"message": "Not your withdraw product."}),
- HTTPStatus.FORBIDDEN,
- )
-
- product = update_diagonalleys_product(product_id, **g.data)
- else:
- product = create_diagonalleys_product(wallet_id=g.wallet.id, **g.data)
-
- return (
- jsonify(product._asdict()),
- HTTPStatus.OK if product_id else HTTPStatus.CREATED,
- )
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/products/", methods=["DELETE"])
-@api_check_wallet_key(key_type="invoice")
-async def api_diagonalley_products_delete(product_id):
- product = get_diagonalleys_product(product_id)
-
- if not product:
- return jsonify({"message": "Product does not exist."}), HTTPStatus.NOT_FOUND
-
- if product.wallet != g.wallet.id:
- return jsonify({"message": "Not your Diagon Alley."}), HTTPStatus.FORBIDDEN
-
- delete_diagonalleys_product(product_id)
-
- return "", HTTPStatus.NO_CONTENT
-
-
-###Indexers
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["GET"])
-@api_check_wallet_key(key_type="invoice")
-async def api_diagonalley_indexers():
- wallet_ids = [g.wallet.id]
-
- if "all_wallets" in request.args:
- wallet_ids = get_user(g.wallet.user).wallet_ids
-
- return (
- jsonify(
- [indexer._asdict() for indexer in get_diagonalleys_indexers(wallet_ids)]
- ),
- HTTPStatus.OK,
- )
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["POST"])
-@diagonalley_ext.route("/api/v1/diagonalley/indexers", methods=["PUT"])
-@api_check_wallet_key(key_type="invoice")
-@api_validate_post_request(
- schema={
- "shopname": {"type": "string", "empty": False, "required": True},
- "indexeraddress": {"type": "string", "empty": False, "required": True},
- "shippingzone1": {"type": "string", "empty": False, "required": True},
- "shippingzone2": {"type": "string", "empty": False, "required": True},
- "email": {"type": "string", "empty": False, "required": True},
- "zone1cost": {"type": "integer", "min": 0, "required": True},
- "zone2cost": {"type": "integer", "min": 0, "required": True},
- }
-)
-async def api_diagonalley_indexer_create(indexer_id=None):
-
- if indexer_id:
- indexer = get_diagonalleys_indexer(indexer_id)
-
- if not indexer:
- return (
- jsonify({"message": "Withdraw indexer does not exist."}),
- HTTPStatus.NOT_FOUND,
- )
-
- if indexer.wallet != g.wallet.id:
- return (
- jsonify({"message": "Not your withdraw indexer."}),
- HTTPStatus.FORBIDDEN,
- )
-
- indexer = update_diagonalleys_indexer(indexer_id, **g.data)
- else:
- indexer = create_diagonalleys_indexer(wallet_id=g.wallet.id, **g.data)
-
- return (
- jsonify(indexer._asdict()),
- HTTPStatus.OK if indexer_id else HTTPStatus.CREATED,
- )
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/indexers/", methods=["DELETE"])
-@api_check_wallet_key(key_type="invoice")
-async def api_diagonalley_indexer_delete(indexer_id):
- indexer = get_diagonalleys_indexer(indexer_id)
-
- if not indexer:
- return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
-
- if indexer.wallet != g.wallet.id:
- return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
-
- delete_diagonalleys_indexer(indexer_id)
-
- return "", HTTPStatus.NO_CONTENT
-
-
-###Orders
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["GET"])
-@api_check_wallet_key(key_type="invoice")
-async def api_diagonalley_orders():
- wallet_ids = [g.wallet.id]
-
- if "all_wallets" in request.args:
- wallet_ids = get_user(g.wallet.user).wallet_ids
-
- return (
- jsonify([order._asdict() for order in get_diagonalleys_orders(wallet_ids)]),
- HTTPStatus.OK,
- )
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/orders", methods=["POST"])
-@api_check_wallet_key(key_type="invoice")
-@api_validate_post_request(
- schema={
- "id": {"type": "string", "empty": False, "required": True},
- "address": {"type": "string", "empty": False, "required": True},
- "email": {"type": "string", "empty": False, "required": True},
- "quantity": {"type": "integer", "empty": False, "required": True},
- "shippingzone": {"type": "integer", "empty": False, "required": True},
- }
-)
-async def api_diagonalley_order_create():
- order = create_diagonalleys_order(wallet_id=g.wallet.id, **g.data)
- return jsonify(order._asdict()), HTTPStatus.CREATED
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/orders/", methods=["DELETE"])
-@api_check_wallet_key(key_type="invoice")
-async def api_diagonalley_order_delete(order_id):
- order = get_diagonalleys_order(order_id)
-
- if not order:
- return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
-
- if order.wallet != g.wallet.id:
- return jsonify({"message": "Not your Indexer."}), HTTPStatus.FORBIDDEN
-
- delete_diagonalleys_indexer(order_id)
-
- return "", HTTPStatus.NO_CONTENT
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/orders/paid/", methods=["GET"])
-@api_check_wallet_key(key_type="invoice")
-async def api_diagonalleys_order_paid(order_id):
- with open_ext_db("diagonalley") as db:
- db.execute(
- "UPDATE orders SET paid = ? WHERE id = ?",
- (
- True,
- order_id,
- ),
- )
- return "", HTTPStatus.OK
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/orders/shipped/", methods=["GET"])
-@api_check_wallet_key(key_type="invoice")
-async def api_diagonalleys_order_shipped(order_id):
- with open_ext_db("diagonalley") as db:
- db.execute(
- "UPDATE orders SET shipped = ? WHERE id = ?",
- (
- True,
- order_id,
- ),
- )
- order = db.fetchone("SELECT * FROM orders WHERE id = ?", (order_id,))
-
- return (
- jsonify(
- [order._asdict() for order in get_diagonalleys_orders(order["wallet"])]
- ),
- HTTPStatus.OK,
- )
-
-
-###List products based on indexer id
-
-
-@diagonalley_ext.route(
- "/api/v1/diagonalley/stall/products/", methods=["GET"]
-)
-async def api_diagonalleys_stall_products(indexer_id):
- with open_ext_db("diagonalley") as db:
- rows = db.fetchone("SELECT * FROM indexers WHERE id = ?", (indexer_id,))
- print(rows[1])
- if not rows:
- return jsonify({"message": "Indexer does not exist."}), HTTPStatus.NOT_FOUND
-
- products = db.fetchone("SELECT * FROM products WHERE wallet = ?", (rows[1],))
- if not products:
- return jsonify({"message": "No products"}), HTTPStatus.NOT_FOUND
-
- return (
- jsonify(
- [products._asdict() for products in get_diagonalleys_products(rows[1])]
- ),
- HTTPStatus.OK,
- )
-
-
-###Check a product has been shipped
-
-
-@diagonalley_ext.route(
- "/api/v1/diagonalley/stall/checkshipped/", methods=["GET"]
-)
-async def api_diagonalleys_stall_checkshipped(checking_id):
- with open_ext_db("diagonalley") as db:
- rows = db.fetchone("SELECT * FROM orders WHERE invoiceid = ?", (checking_id,))
-
- return jsonify({"shipped": rows["shipped"]}), HTTPStatus.OK
-
-
-###Place order
-
-
-@diagonalley_ext.route("/api/v1/diagonalley/stall/order/", methods=["POST"])
-@api_validate_post_request(
- schema={
- "id": {"type": "string", "empty": False, "required": True},
- "email": {"type": "string", "empty": False, "required": True},
- "address": {"type": "string", "empty": False, "required": True},
- "quantity": {"type": "integer", "empty": False, "required": True},
- "shippingzone": {"type": "integer", "empty": False, "required": True},
- }
-)
-async def api_diagonalley_stall_order(indexer_id):
- product = get_diagonalleys_product(g.data["id"])
- shipping = get_diagonalleys_indexer(indexer_id)
-
- if g.data["shippingzone"] == 1:
- shippingcost = shipping.zone1cost
- else:
- shippingcost = shipping.zone2cost
-
- checking_id, payment_request = create_invoice(
- wallet_id=product.wallet,
- amount=shippingcost + (g.data["quantity"] * product.price),
- memo=g.data["id"],
- )
- selling_id = urlsafe_b64encode(uuid4().bytes_le).decode("utf-8")
- with open_ext_db("diagonalley") as db:
- db.execute(
- """
- INSERT INTO orders (id, productid, wallet, product, quantity, shippingzone, address, email, invoiceid, paid, shipped)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- selling_id,
- g.data["id"],
- product.wallet,
- product.product,
- g.data["quantity"],
- g.data["shippingzone"],
- g.data["address"],
- g.data["email"],
- checking_id,
- False,
- False,
- ),
- )
- return (
- jsonify({"checking_id": checking_id, "payment_request": payment_request}),
- HTTPStatus.OK,
- )
diff --git a/lnbits/extensions/events/__init__.py b/lnbits/extensions/events/__init__.py
index b8f4deb5..d0aa27bc 100644
--- a/lnbits/extensions/events/__init__.py
+++ b/lnbits/extensions/events/__init__.py
@@ -1,13 +1,17 @@
-from quart import Blueprint
+from fastapi import APIRouter
+
from lnbits.db import Database
+from lnbits.helpers import template_renderer
db = Database("ext_events")
-events_ext: Blueprint = Blueprint(
- "events", __name__, static_folder="static", template_folder="templates"
-)
+events_ext: APIRouter = APIRouter(prefix="/events", tags=["Events"])
+
+
+def events_renderer():
+ return template_renderer(["lnbits/extensions/events/templates"])
-from .views_api import * # noqa
from .views import * # noqa
+from .views_api import * # noqa
diff --git a/lnbits/extensions/events/crud.py b/lnbits/extensions/events/crud.py
index 20faa09a..9e04476d 100644
--- a/lnbits/extensions/events/crud.py
+++ b/lnbits/extensions/events/crud.py
@@ -3,8 +3,7 @@ from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
-from .models import Tickets, Events
-
+from .models import CreateEvent, Events, Tickets
# TICKETS
@@ -14,7 +13,7 @@ async def create_ticket(
) -> Tickets:
await db.execute(
"""
- INSERT INTO ticket (id, wallet, event, name, email, registered, paid)
+ INSERT INTO events.ticket (id, wallet, event, name, email, registered, paid)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(payment_hash, wallet, event, name, email, False, False),
@@ -26,11 +25,11 @@ async def create_ticket(
async def set_ticket_paid(payment_hash: str) -> Tickets:
- row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
+ row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
if row[6] != True:
await db.execute(
"""
- UPDATE ticket
+ UPDATE events.ticket
SET paid = true
WHERE id = ?
""",
@@ -44,7 +43,7 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
amount_tickets = eventdata.amount_tickets - 1
await db.execute(
"""
- UPDATE events
+ UPDATE events.events
SET sold = ?, amount_tickets = ?
WHERE id = ?
""",
@@ -57,7 +56,7 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
async def get_ticket(payment_hash: str) -> Optional[Tickets]:
- row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
+ row = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (payment_hash,))
return Tickets(**row) if row else None
@@ -67,45 +66,39 @@ async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
- f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,)
+ f"SELECT * FROM events.ticket WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Tickets(**row) for row in rows]
async def delete_ticket(payment_hash: str) -> None:
- await db.execute("DELETE FROM ticket WHERE id = ?", (payment_hash,))
+ await db.execute("DELETE FROM events.ticket WHERE id = ?", (payment_hash,))
+
+
+async def delete_event_tickets(event_id: str) -> None:
+ await db.execute("DELETE FROM events.ticket WHERE event = ?", (event_id,))
# EVENTS
-async def create_event(
- *,
- wallet: str,
- name: str,
- info: str,
- closing_date: str,
- event_start_date: str,
- event_end_date: str,
- amount_tickets: int,
- price_per_ticket: int,
-) -> Events:
+async def create_event(data: CreateEvent) -> Events:
event_id = urlsafe_short_hash()
await db.execute(
"""
- INSERT INTO events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
+ INSERT INTO events.events (id, wallet, name, info, closing_date, event_start_date, event_end_date, amount_tickets, price_per_ticket, sold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
event_id,
- wallet,
- name,
- info,
- closing_date,
- event_start_date,
- event_end_date,
- amount_tickets,
- price_per_ticket,
+ data.wallet,
+ data.name,
+ data.info,
+ data.closing_date,
+ data.event_start_date,
+ data.event_end_date,
+ data.amount_tickets,
+ data.price_per_ticket,
0,
),
)
@@ -118,7 +111,7 @@ async def create_event(
async def update_event(event_id: str, **kwargs) -> Events:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
- f"UPDATE events SET {q} WHERE id = ?", (*kwargs.values(), event_id)
+ f"UPDATE events.events SET {q} WHERE id = ?", (*kwargs.values(), event_id)
)
event = await get_event(event_id)
assert event, "Newly updated event couldn't be retrieved"
@@ -126,7 +119,7 @@ async def update_event(event_id: str, **kwargs) -> Events:
async def get_event(event_id: str) -> Optional[Events]:
- row = await db.fetchone("SELECT * FROM events WHERE id = ?", (event_id,))
+ row = await db.fetchone("SELECT * FROM events.events WHERE id = ?", (event_id,))
return Events(**row) if row else None
@@ -136,14 +129,14 @@ async def get_events(wallet_ids: Union[str, List[str]]) -> List[Events]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
- f"SELECT * FROM events WHERE wallet IN ({q})", (*wallet_ids,)
+ f"SELECT * FROM events.events WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Events(**row) for row in rows]
async def delete_event(event_id: str) -> None:
- await db.execute("DELETE FROM events WHERE id = ?", (event_id,))
+ await db.execute("DELETE FROM events.events WHERE id = ?", (event_id,))
# EVENTTICKETS
@@ -151,13 +144,18 @@ async def delete_event(event_id: str) -> None:
async def get_event_tickets(event_id: str, wallet_id: str) -> List[Tickets]:
rows = await db.fetchall(
- "SELECT * FROM ticket WHERE wallet = ? AND event = ?", (wallet_id, event_id)
+ "SELECT * FROM events.ticket WHERE wallet = ? AND event = ?",
+ (wallet_id, event_id),
)
return [Tickets(**row) for row in rows]
async def reg_ticket(ticket_id: str) -> List[Tickets]:
- await db.execute("UPDATE ticket SET registered = ? WHERE id = ?", (True, ticket_id))
- ticket = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (ticket_id,))
- rows = await db.fetchall("SELECT * FROM ticket WHERE event = ?", (ticket[1],))
+ await db.execute(
+ "UPDATE events.ticket SET registered = ? WHERE id = ?", (True, ticket_id)
+ )
+ ticket = await db.fetchone("SELECT * FROM events.ticket WHERE id = ?", (ticket_id,))
+ rows = await db.fetchall(
+ "SELECT * FROM events.ticket WHERE event = ?", (ticket[1],)
+ )
return [Tickets(**row) for row in rows]
diff --git a/lnbits/extensions/events/migrations.py b/lnbits/extensions/events/migrations.py
index 5d0e5840..5b9d53b0 100644
--- a/lnbits/extensions/events/migrations.py
+++ b/lnbits/extensions/events/migrations.py
@@ -2,7 +2,7 @@ async def m001_initial(db):
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS events (
+ CREATE TABLE events.events (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
@@ -13,21 +13,25 @@ async def m001_initial(db):
amount_tickets INTEGER NOT NULL,
price_per_ticket INTEGER NOT NULL,
sold INTEGER NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS tickets (
+ CREATE TABLE events.tickets (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
event TEXT NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
registered BOOLEAN NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
@@ -37,7 +41,7 @@ async def m002_changed(db):
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS ticket (
+ CREATE TABLE events.ticket (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
event TEXT NOT NULL,
@@ -45,12 +49,14 @@ async def m002_changed(db):
email TEXT NOT NULL,
registered BOOLEAN NOT NULL,
paid BOOLEAN NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
- for row in [list(row) for row in await db.fetchall("SELECT * FROM tickets")]:
+ for row in [list(row) for row in await db.fetchall("SELECT * FROM events.tickets")]:
usescsv = ""
for i in range(row[5]):
@@ -61,7 +67,7 @@ async def m002_changed(db):
usescsv = usescsv[1:]
await db.execute(
"""
- INSERT INTO ticket (
+ INSERT INTO events.ticket (
id,
wallet,
event,
@@ -72,14 +78,6 @@ async def m002_changed(db):
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
- (
- row[0],
- row[1],
- row[2],
- row[3],
- row[4],
- row[5],
- True,
- ),
+ (row[0], row[1], row[2], row[3], row[4], row[5], True),
)
- await db.execute("DROP TABLE tickets")
+ await db.execute("DROP TABLE events.tickets")
diff --git a/lnbits/extensions/events/models.py b/lnbits/extensions/events/models.py
index 0f79fa41..dd38e973 100644
--- a/lnbits/extensions/events/models.py
+++ b/lnbits/extensions/events/models.py
@@ -1,7 +1,24 @@
-from typing import NamedTuple
+from fastapi.param_functions import Query
+from pydantic import BaseModel
-class Events(NamedTuple):
+class CreateEvent(BaseModel):
+ wallet: str
+ name: str
+ info: str
+ closing_date: str
+ event_start_date: str
+ event_end_date: str
+ amount_tickets: int = Query(..., ge=0)
+ price_per_ticket: int = Query(..., ge=0)
+
+
+class CreateTicket(BaseModel):
+ name: str
+ email: str
+
+
+class Events(BaseModel):
id: str
wallet: str
name: str
@@ -15,7 +32,7 @@ class Events(NamedTuple):
time: int
-class Tickets(NamedTuple):
+class Tickets(BaseModel):
id: str
wallet: str
event: str
diff --git a/lnbits/extensions/events/templates/events/display.html b/lnbits/extensions/events/templates/events/display.html
index be10b08c..4c1f557f 100644
--- a/lnbits/extensions/events/templates/events/display.html
+++ b/lnbits/extensions/events/templates/events/display.html
@@ -26,7 +26,7 @@
Submit Link to your ticket!
diff --git a/lnbits/extensions/events/templates/events/index.html b/lnbits/extensions/events/templates/events/index.html
index 384cf630..409ed2af 100644
--- a/lnbits/extensions/events/templates/events/index.html
+++ b/lnbits/extensions/events/templates/events/index.html
@@ -4,7 +4,7 @@
- New Event
@@ -164,7 +164,9 @@
- LNbits Events extension
+
+ {{SITE_TITLE}} Events extension
+
@@ -267,14 +269,14 @@
Update Event
Create Event
- Scan ticket
@@ -82,7 +82,7 @@
-{% endblock %}
diff --git a/lnbits/extensions/example/views.py b/lnbits/extensions/example/views.py
deleted file mode 100644
index 99e58f62..00000000
--- a/lnbits/extensions/example/views.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from quart import g, render_template
-
-from lnbits.decorators import check_user_exists, validate_uuids
-
-from . import example_ext
-
-
-@example_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("example/index.html", user=g.user)
diff --git a/lnbits/extensions/example/views_api.py b/lnbits/extensions/example/views_api.py
deleted file mode 100644
index e59c1072..00000000
--- a/lnbits/extensions/example/views_api.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# views_api.py is for you API endpoints that could be hit by another service
-
-# add your dependencies here
-
-# import json
-# import httpx
-# (use httpx just like requests, except instead of response.ok there's only the
-# response.is_error that is its inverse)
-
-from quart import jsonify
-from http import HTTPStatus
-
-from . import example_ext
-
-
-# add your endpoints here
-
-
-@example_ext.route("/api/v1/tools", methods=["GET"])
-async def api_example():
- """Try to add descriptions for others."""
- tools = [
- {
- "name": "Quart",
- "url": "https://pgjones.gitlab.io/quart/",
- "language": "Python",
- },
- {
- "name": "Vue.js",
- "url": "https://vuejs.org/",
- "language": "JavaScript",
- },
- {
- "name": "Quasar Framework",
- "url": "https://quasar.dev/",
- "language": "JavaScript",
- },
- ]
-
- return jsonify(tools), HTTPStatus.OK
diff --git a/lnbits/extensions/hivemind/README.md b/lnbits/extensions/hivemind/README.md
new file mode 100644
index 00000000..1e9667ec
--- /dev/null
+++ b/lnbits/extensions/hivemind/README.md
@@ -0,0 +1,3 @@
+Hivemind
+
+Placeholder for a future Bitcoin Hivemind extension.
diff --git a/lnbits/extensions/hivemind/__init__.py b/lnbits/extensions/hivemind/__init__.py
new file mode 100644
index 00000000..3aebd05f
--- /dev/null
+++ b/lnbits/extensions/hivemind/__init__.py
@@ -0,0 +1,15 @@
+from fastapi import APIRouter
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+
+db = Database("ext_hivemind")
+
+hivemind_ext: APIRouter = APIRouter(prefix="/hivemind", tags=["hivemind"])
+
+
+def hivemind_renderer():
+ return template_renderer(["lnbits/extensions/hivemind/templates"])
+
+
+from .views import * # noqa
diff --git a/lnbits/extensions/hivemind/config.json b/lnbits/extensions/hivemind/config.json
new file mode 100644
index 00000000..a5469b15
--- /dev/null
+++ b/lnbits/extensions/hivemind/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Hivemind",
+ "short_description": "Make cheap talk expensive!",
+ "icon": "batch_prediction",
+ "contributors": ["fiatjaf"]
+}
diff --git a/lnbits/extensions/hivemind/migrations.py b/lnbits/extensions/hivemind/migrations.py
new file mode 100644
index 00000000..775a9454
--- /dev/null
+++ b/lnbits/extensions/hivemind/migrations.py
@@ -0,0 +1,10 @@
+# async def m001_initial(db):
+# await db.execute(
+# f"""
+# CREATE TABLE hivemind.hivemind (
+# id TEXT PRIMARY KEY,
+# wallet TEXT NOT NULL,
+# time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
+# );
+# """
+# )
diff --git a/lnbits/extensions/example/models.py b/lnbits/extensions/hivemind/models.py
similarity index 100%
rename from lnbits/extensions/example/models.py
rename to lnbits/extensions/hivemind/models.py
diff --git a/lnbits/extensions/hivemind/templates/hivemind/index.html b/lnbits/extensions/hivemind/templates/hivemind/index.html
new file mode 100644
index 00000000..40a320f0
--- /dev/null
+++ b/lnbits/extensions/hivemind/templates/hivemind/index.html
@@ -0,0 +1,35 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+ This extension is just a placeholder for now.
+
+
+ Hivemind is a Bitcoin sidechain
+ project for a peer-to-peer oracle protocol that absorbs accurate data into
+ a blockchain so that Bitcoin users can speculate in prediction markets.
+
+
+ These markets have the potential to revolutionize the emergence of
+ diffusion of knowledge in society and fix all sorts of problems in the
+ world.
+
+
+ This extension will become fully operative when the
+ BIP300 soft-fork gets activated and
+ Bitcoin Hivemind is launched.
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/hivemind/views.py b/lnbits/extensions/hivemind/views.py
new file mode 100644
index 00000000..5c2475cf
--- /dev/null
+++ b/lnbits/extensions/hivemind/views.py
@@ -0,0 +1,15 @@
+from fastapi.param_functions import Depends
+from starlette.requests import Request
+from starlette.responses import HTMLResponse
+
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import hivemind_ext, hivemind_renderer
+
+
+@hivemind_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return hivemind_renderer().TemplateResponse(
+ "hivemind/index.html", {"request": request, "user": user.dict()}
+ )
diff --git a/lnbits/extensions/jukebox/README.md b/lnbits/extensions/jukebox/README.md
index b92e7ea6..c761db44 100644
--- a/lnbits/extensions/jukebox/README.md
+++ b/lnbits/extensions/jukebox/README.md
@@ -1,5 +1,36 @@
# Jukebox
-To use this extension you need a Spotify client ID and client secret. You get these by creating an app in the Spotify developers dashboard here https://developer.spotify.com/dashboard/applications
+## An actual Jukebox where users pay sats to play their favourite music from your playlists
-Select the playlists you want people to be able to pay for, share the frontend page, profit :)
+**Note:** To use this extension you need a Premium Spotify subscription.
+
+## Usage
+
+1. Click on "ADD SPOTIFY JUKEBOX"\
+ 
+2. Follow the steps required on the form\
+
+ - give your jukebox a name
+ - select a wallet to receive payment
+ - define the price a user must pay to select a song\
+ 
+ - follow the steps to get your Spotify App and get the client ID and secret key\
+ 
+ - paste the codes in the form\
+ 
+ - copy the _Redirect URL_ presented on the form\
+ 
+ - on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
+ 
+ - back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
+ - choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
+ - and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
+ 
+
+3. After Jukebox is created, click the icon to open the dialog with the shareable QR, open the Jukebox page, etc...\
+ 
+4. The users will see the Jukebox page and choose a song from the selected playlist\
+ 
+5. After selecting a song they'd like to hear next a dialog will show presenting the music\
+ 
+6. After payment, the song will automatically start playing on the device selected or enter the queue if some other music is already playing
diff --git a/lnbits/extensions/jukebox/__init__.py b/lnbits/extensions/jukebox/__init__.py
index b6ec402f..702a6c67 100644
--- a/lnbits/extensions/jukebox/__init__.py
+++ b/lnbits/extensions/jukebox/__init__.py
@@ -1,12 +1,34 @@
-from quart import Blueprint
+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_jukebox")
-jukebox_ext: Blueprint = Blueprint(
- "jukebox", __name__, static_folder="static", template_folder="templates"
-)
+jukebox_static_files = [
+ {
+ "path": "/jukebox/static",
+ "app": StaticFiles(directory="lnbits/extensions/jukebox/static"),
+ "name": "jukebox_static",
+ }
+]
-from .views_api import * # noqa
+jukebox_ext: APIRouter = APIRouter(prefix="/jukebox", tags=["jukebox"])
+
+
+def jukebox_renderer():
+ return template_renderer(["lnbits/extensions/jukebox/templates"])
+
+
+from .tasks import wait_for_paid_invoices
from .views import * # noqa
+from .views_api import * # noqa
+
+
+def jukebox_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/jukebox/config.json b/lnbits/extensions/jukebox/config.json
index 91134bc2..6b57bec4 100644
--- a/lnbits/extensions/jukebox/config.json
+++ b/lnbits/extensions/jukebox/config.json
@@ -1,5 +1,5 @@
{
- "name": "SpotifyJukebox",
+ "name": "Spotify Jukebox",
"short_description": "Spotify jukebox middleware",
"icon": "radio",
"contributors": ["benarc"]
diff --git a/lnbits/extensions/jukebox/crud.py b/lnbits/extensions/jukebox/crud.py
index a24d8a7e..caaac7e5 100644
--- a/lnbits/extensions/jukebox/crud.py
+++ b/lnbits/extensions/jukebox/crud.py
@@ -1,41 +1,32 @@
from typing import List, Optional
-from . import db
-from .models import Jukebox, JukeboxPayment
from lnbits.helpers import urlsafe_short_hash
+from . import db
+from .models import CreateJukeboxPayment, CreateJukeLinkData, Jukebox, JukeboxPayment
+
async def create_jukebox(
- inkey: str,
- user: str,
- wallet: str,
- title: str,
- price: int,
- sp_user: str,
- sp_secret: str,
- sp_access_token: Optional[str] = "",
- sp_refresh_token: Optional[str] = "",
- sp_device: Optional[str] = "",
- sp_playlists: Optional[str] = "",
+ data: CreateJukeLinkData, inkey: Optional[str] = ""
) -> Jukebox:
juke_id = urlsafe_short_hash()
result = await db.execute(
"""
- INSERT INTO jukebox (id, user, title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
+ INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
juke_id,
- user,
- title,
- wallet,
- sp_user,
- sp_secret,
- sp_access_token,
- sp_refresh_token,
- sp_device,
- sp_playlists,
- int(price),
+ data.user,
+ data.title,
+ data.wallet,
+ data.sp_user,
+ data.sp_secret,
+ data.sp_access_token,
+ data.sp_refresh_token,
+ data.sp_device,
+ data.sp_playlists,
+ data.price,
0,
),
)
@@ -44,38 +35,42 @@ async def create_jukebox(
return jukebox
-async def update_jukebox(juke_id: str, **kwargs) -> Optional[Jukebox]:
- q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
- await db.execute(
- f"UPDATE jukebox SET {q} WHERE id = ?", (*kwargs.values(), juke_id)
- )
- row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (juke_id,))
+async def update_jukebox(
+ data: CreateJukeLinkData, juke_id: Optional[str] = ""
+) -> Optional[Jukebox]:
+ q = ", ".join([f"{field[0]} = ?" for field in data])
+ items = [f"{field[1]}" for field in data]
+ items.append(juke_id)
+ q = q.replace("user", '"user"', 1) # hack to make user be "user"!
+ await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items))
+ row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
return Jukebox(**row) if row else None
async def get_jukebox(juke_id: str) -> Optional[Jukebox]:
- row = await db.fetchone("SELECT * FROM jukebox WHERE id = ?", (juke_id,))
+ row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
return Jukebox(**row) if row else None
async def get_jukebox_by_user(user: str) -> Optional[Jukebox]:
- row = await db.fetchone("SELECT * FROM jukebox WHERE sp_user = ?", (user,))
+ row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE sp_user = ?", (user,))
return Jukebox(**row) if row else None
async def get_jukeboxs(user: str) -> List[Jukebox]:
- rows = await db.fetchall("SELECT * FROM jukebox WHERE user = ?", (user,))
+ rows = await db.fetchall('SELECT * FROM jukebox.jukebox WHERE "user" = ?', (user,))
for row in rows:
- if row.sp_playlists == "":
+ if row.sp_playlists == None:
await delete_jukebox(row.id)
- rows = await db.fetchall("SELECT * FROM jukebox WHERE user = ?", (user,))
- return [Jukebox.from_row(row) for row in rows]
+ rows = await db.fetchall('SELECT * FROM jukebox.jukebox WHERE "user" = ?', (user,))
+
+ return [Jukebox(**row) for row in rows]
async def delete_jukebox(juke_id: str):
await db.execute(
"""
- DELETE FROM jukebox WHERE id = ?
+ DELETE FROM jukebox.jukebox WHERE id = ?
""",
(juke_id),
)
@@ -84,22 +79,15 @@ async def delete_jukebox(juke_id: str):
#####################################PAYMENTS
-async def create_jukebox_payment(
- song_id: str, payment_hash: str, juke_id: str
-) -> JukeboxPayment:
+async def create_jukebox_payment(data: CreateJukeboxPayment) -> JukeboxPayment:
result = await db.execute(
"""
- INSERT INTO jukebox_payment (payment_hash, juke_id, song_id, paid)
+ INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
VALUES (?, ?, ?, ?)
""",
- (
- payment_hash,
- juke_id,
- song_id,
- False,
- ),
+ (data.payment_hash, data.juke_id, data.song_id, False),
)
- jukebox_payment = await get_jukebox_payment(payment_hash)
+ jukebox_payment = await get_jukebox_payment(data.payment_hash)
assert jukebox_payment, "Newly created Jukebox Payment couldn't be retrieved"
return jukebox_payment
@@ -109,7 +97,7 @@ async def update_jukebox_payment(
) -> Optional[JukeboxPayment]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
- f"UPDATE jukebox_payment SET {q} WHERE payment_hash = ?",
+ f"UPDATE jukebox.jukebox_payment SET {q} WHERE payment_hash = ?",
(*kwargs.values(), payment_hash),
)
return await get_jukebox_payment(payment_hash)
@@ -117,6 +105,6 @@ async def update_jukebox_payment(
async def get_jukebox_payment(payment_hash: str) -> Optional[JukeboxPayment]:
row = await db.fetchone(
- "SELECT * FROM jukebox_payment WHERE payment_hash = ?", (payment_hash,)
+ "SELECT * FROM jukebox.jukebox_payment WHERE payment_hash = ?", (payment_hash,)
)
return JukeboxPayment(**row) if row else None
diff --git a/lnbits/extensions/jukebox/migrations.py b/lnbits/extensions/jukebox/migrations.py
index 7d4fe2e3..a0a3bd28 100644
--- a/lnbits/extensions/jukebox/migrations.py
+++ b/lnbits/extensions/jukebox/migrations.py
@@ -4,9 +4,9 @@ async def m001_initial(db):
"""
await db.execute(
"""
- CREATE TABLE jukebox (
+ CREATE TABLE jukebox.jukebox (
id TEXT PRIMARY KEY,
- user TEXT,
+ "user" TEXT,
title TEXT,
wallet TEXT,
inkey TEXT,
@@ -29,7 +29,7 @@ async def m002_initial(db):
"""
await db.execute(
"""
- CREATE TABLE jukebox_payment (
+ CREATE TABLE jukebox.jukebox_payment (
payment_hash TEXT PRIMARY KEY,
juke_id TEXT,
song_id TEXT,
diff --git a/lnbits/extensions/jukebox/models.py b/lnbits/extensions/jukebox/models.py
index 03c41d67..093961e4 100644
--- a/lnbits/extensions/jukebox/models.py
+++ b/lnbits/extensions/jukebox/models.py
@@ -1,33 +1,50 @@
from typing import NamedTuple
from sqlite3 import Row
+from fastapi.param_functions import Query
+from pydantic.main import BaseModel
+from pydantic import BaseModel
+from typing import Optional
-class Jukebox(NamedTuple):
- id: str
- user: str
- title: str
- wallet: str
- inkey: str
- sp_user: str
- sp_secret: str
- sp_access_token: str
- sp_refresh_token: str
- sp_device: str
- sp_playlists: str
- price: int
- profit: int
-
- @classmethod
- def from_row(cls, row: Row) -> "Jukebox":
- return cls(**dict(row))
+class CreateJukeLinkData(BaseModel):
+ user: str = Query(None)
+ title: str = Query(None)
+ wallet: str = Query(None)
+ sp_user: str = Query(None)
+ sp_secret: str = Query(None)
+ sp_access_token: str = Query(None)
+ sp_refresh_token: str = Query(None)
+ sp_device: str = Query(None)
+ sp_playlists: str = Query(None)
+ price: str = Query(None)
-class JukeboxPayment(NamedTuple):
+class Jukebox(BaseModel):
+ id: Optional[str]
+ user: Optional[str]
+ title: Optional[str]
+ wallet: Optional[str]
+ inkey: Optional[str]
+ sp_user: Optional[str]
+ sp_secret: Optional[str]
+ sp_access_token: Optional[str]
+ sp_refresh_token: Optional[str]
+ sp_device: Optional[str]
+ sp_playlists: Optional[str]
+ price: Optional[int]
+ profit: Optional[int]
+
+
+class JukeboxPayment(BaseModel):
payment_hash: str
juke_id: str
song_id: str
paid: bool
- @classmethod
- def from_row(cls, row: Row) -> "JukeboxPayment":
- return cls(**dict(row))
+
+class CreateJukeboxPayment(BaseModel):
+ invoice: str = Query(None)
+ payment_hash: str = Query(None)
+ juke_id: str = Query(None)
+ song_id: str = Query(None)
+ paid: bool = Query(False)
diff --git a/lnbits/extensions/jukebox/static/js/index.js b/lnbits/extensions/jukebox/static/js/index.js
index 57f9c678..049b600e 100644
--- a/lnbits/extensions/jukebox/static/js/index.js
+++ b/lnbits/extensions/jukebox/static/js/index.js
@@ -3,17 +3,25 @@
Vue.component(VueQrcode.name, VueQrcode)
var mapJukebox = obj => {
- obj._data = _.clone(obj)
- obj.sp_id = obj.id
- obj.device = obj.sp_device.split('-')[0]
- playlists = obj.sp_playlists.split(',')
- var i
- playlistsar = []
- for (i = 0; i < playlists.length; i++) {
- playlistsar.push(playlists[i].split('-')[0])
+ if(obj.sp_device){
+ obj._data = _.clone(obj)
+
+ obj.sp_id = obj._data.id
+ obj.device = obj._data.sp_device.split('-')[0]
+ playlists = obj._data.sp_playlists.split(',')
+ var i
+ playlistsar = []
+ for (i = 0; i < playlists.length; i++) {
+ playlistsar.push(playlists[i].split('-')[0])
+ }
+ obj.playlist = playlistsar.join()
+ console.log(obj)
+ return obj
}
- obj.playlist = playlistsar.join()
- return obj
+ else {
+ return
+ }
+
}
new Vue({
@@ -46,12 +54,6 @@ new Vue({
align: 'left',
label: 'Price',
field: 'price'
- },
- {
- name: 'profit',
- align: 'left',
- label: 'Profit',
- field: 'profit'
}
],
pagination: {
@@ -85,20 +87,26 @@ new Vue({
var link = _.findWhere(this.JukeboxLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link)
- console.log(this.qrCodeDialog.data)
+
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true
},
getJukeboxes() {
self = this
+
LNbits.api
- .request('GET', '/jukebox/api/v1/jukebox', self.g.user.wallets[0].adminkey)
+ .request(
+ 'GET',
+ '/jukebox/api/v1/jukebox',
+ self.g.user.wallets[0].adminkey
+ )
.then(function (response) {
- self.JukeboxLinks = response.data.map(mapJukebox)
- })
- .catch(err => {
- LNbits.utils.notifyApiError(err)
+ self.JukeboxLinks = response.data.map(function (obj) {
+
+ return mapJukebox(obj)
+ })
+ console.log(self.JukeboxLinks)
})
},
deleteJukebox(juke_id) {
@@ -127,7 +135,6 @@ new Vue({
self = this
var link = _.findWhere(self.JukeboxLinks, {id: linkId})
self.jukeboxDialog.data = _.clone(link._data)
- console.log(this.jukeboxDialog.data.sp_access_token)
self.refreshDevices()
self.refreshPlaylists()
@@ -147,7 +154,7 @@ new Vue({
submitSpotifyKeys() {
self = this
self.jukeboxDialog.data.user = self.g.user.id
-
+
LNbits.api
.request(
'POST',
@@ -165,10 +172,10 @@ new Vue({
LNbits.utils.notifyApiError(err)
})
},
- authAccess() {
+ authAccess() {
self = this
- self.requestAuthorization()
- self.getSpotifyTokens()
+ self.requestAuthorization()
+ self.getSpotifyTokens()
self.$q.notify({
spinner: true,
message: 'Processing',
@@ -195,37 +202,34 @@ new Vue({
if (self.jukeboxDialog.data.sp_access_token) {
self.refreshPlaylists()
self.refreshDevices()
- console.log("this.devices")
- console.log(self.devices)
- console.log("this.devices")
setTimeout(function () {
- if (self.devices.length < 1 || self.playlists.length < 1) {
- self.$q.notify({
- spinner: true,
- color: 'red',
- message:
- 'Error! Make sure Spotify is open on the device you wish to use, has playlists, and is playing something',
- timeout: 10000
- })
- LNbits.api
- .request(
- 'DELETE',
- '/jukebox/api/v1/jukebox/' + response.data.id,
- self.g.user.wallets[0].adminkey
- )
- .then(function (response) {
- self.getJukeboxes()
+ if (self.devices.length < 1 || self.playlists.length < 1) {
+ self.$q.notify({
+ spinner: true,
+ color: 'red',
+ message:
+ 'Error! Make sure Spotify is open on the device you wish to use, has playlists, and is playing something',
+ timeout: 10000
})
- .catch(err => {
- LNbits.utils.notifyApiError(err)
- })
- clearInterval(timerId)
- self.closeFormDialog()
- } else {
- self.step = 4
- clearInterval(timerId)
- }
- }, 2000)
+ LNbits.api
+ .request(
+ 'DELETE',
+ '/jukebox/api/v1/jukebox/' + response.data.id,
+ self.g.user.wallets[0].adminkey
+ )
+ .then(function (response) {
+ self.getJukeboxes()
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ clearInterval(timerId)
+ self.closeFormDialog()
+ } else {
+ self.step = 4
+ clearInterval(timerId)
+ }
+ }, 2000)
}
}
})
@@ -261,16 +265,14 @@ new Vue({
},
updateDB() {
self = this
- console.log(self.jukeboxDialog.data)
LNbits.api
.request(
'PUT',
- '/jukebox/api/v1/jukebox/' + this.jukeboxDialog.data.sp_id,
+ '/jukebox/api/v1/jukebox/' + self.jukeboxDialog.data.sp_id,
self.g.user.wallets[0].adminkey,
self.jukeboxDialog.data
)
.then(function (response) {
- console.log(response.data)
if (
self.jukeboxDialog.data.sp_playlists &&
self.jukeboxDialog.data.sp_devices
@@ -309,7 +311,6 @@ new Vue({
responseObj.items[i].name + '-' + responseObj.items[i].id
)
}
- console.log(self.playlists)
}
},
refreshPlaylists() {
@@ -347,15 +348,15 @@ new Vue({
}
}
},
- refreshDevices() {
+ refreshDevices() {
self = this
- self.deviceApi(
+ self.deviceApi(
'GET',
'https://api.spotify.com/v1/me/player/devices',
null
)
},
- fetchAccessToken(code) {
+ fetchAccessToken(code) {
self = this
let body = 'grant_type=authorization_code'
body += '&code=' + code
@@ -363,24 +364,17 @@ new Vue({
'&redirect_uri=' +
encodeURI(self.locationcbPath + self.jukeboxDialog.data.sp_id)
- self.callAuthorizationApi(body)
+ self.callAuthorizationApi(body)
},
- refreshAccessToken() {
+ refreshAccessToken() {
self = this
let body = 'grant_type=refresh_token'
body += '&refresh_token=' + self.jukeboxDialog.data.sp_refresh_token
body += '&client_id=' + self.jukeboxDialog.data.sp_user
- self.callAuthorizationApi(body)
+ self.callAuthorizationApi(body)
},
- callAuthorizationApi(body) {
+ callAuthorizationApi(body) {
self = this
- console.log(
- btoa(
- self.jukeboxDialog.data.sp_user +
- ':' +
- self.jukeboxDialog.data.sp_secret
- )
- )
let xhr = new XMLHttpRequest()
xhr.open('POST', 'https://accounts.spotify.com/api/token', true)
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
@@ -405,7 +399,6 @@ new Vue({
}
},
created() {
- console.log(this.g.user.wallets[0])
var getJukeboxes = this.getJukeboxes
getJukeboxes()
this.selectedWallet = this.g.user.wallets[0]
diff --git a/lnbits/extensions/jukebox/static/js/jukebox.js b/lnbits/extensions/jukebox/static/js/jukebox.js
deleted file mode 100644
index b6e26f13..00000000
--- a/lnbits/extensions/jukebox/static/js/jukebox.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
-
-Vue.component(VueQrcode.name, VueQrcode)
-
-new Vue({
- el: '#vue',
- mixins: [windowMixin],
- data() {
- return {
- }
- },
- computed: {},
- methods: {
-
- },
- created() {
-
- }
-})
diff --git a/lnbits/extensions/jukebox/tasks.py b/lnbits/extensions/jukebox/tasks.py
new file mode 100644
index 00000000..02241c7b
--- /dev/null
+++ b/lnbits/extensions/jukebox/tasks.py
@@ -0,0 +1,22 @@
+import asyncio
+
+from lnbits.core.models import Payment
+from lnbits.tasks import register_invoice_listener
+
+from .crud import update_jukebox_payment
+
+
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
+
+ while True:
+ payment = await invoice_queue.get()
+ await on_invoice_paid(payment)
+
+
+async def on_invoice_paid(payment: Payment) -> None:
+ if "jukebox" != payment.extra.get("tag"):
+ # not a jukebox invoice
+ return
+ await update_jukebox_payment(payment.payment_hash, paid=True)
diff --git a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html
index 66c2dfcf..791a55e7 100644
--- a/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html
+++ b/lnbits/extensions/jukebox/templates/jukebox/_api_docs.html
@@ -1,24 +1,33 @@
- To use this extension you need a Spotify client ID and client secret. You
- get these by creating an app in the Spotify developers dashboard
- here
-
Select the playlists you want people to be able to pay for,
- share the frontend page, profit :)
- Made by, benarc. Inspired by,
- pirosb3.
+ To use this extension you need a Spotify client ID and client secret. You get
+ these by creating an app in the Spotify developers dashboard
+ here
+
+
Select the playlists you want people to be able to pay for, share
+ the frontend page, profit :)
+ Made by,
+ benarc.
+ Inspired by,
+ pirosb3.
-
-
-
-
-
-
+
- GET
- /jukebox/api/v1/jukebox
+ GET /jukebox/api/v1/jukebox
Headers
{"X-Api-Key": <admin_key>}
Body (application/json)
@@ -27,8 +36,9 @@
[<jukebox_object>, ...]
Curl example
- curl -X GET {{ request.url_root }}api/v1/jukebox -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
+ curl -X GET {{ request.base_url }}api/v1/jukebox -H "X-Api-Key: {{
+ user.wallets[0].adminkey }}"
@@ -36,8 +46,10 @@
- GET
- /jukebox/api/v1/jukebox/<juke_id>
+ GET
+ /jukebox/api/v1/jukebox/<juke_id>
Headers
{"X-Api-Key": <admin_key>}
Body (application/json)
@@ -46,36 +58,44 @@
<jukebox_object>
Curl example
- curl -X GET {{ request.url_root }}api/v1/jukebox/<juke_id> -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
+ curl -X GET {{ request.base_url }}api/v1/jukebox/<juke_id> -H
+ "X-Api-Key: {{ user.wallets[0].adminkey }}"
-
+
- POST/PUT
- /jukebox/api/v1/jukebox/
+ POST/PUT
+ /jukebox/api/v1/jukebox/
Headers
{"X-Api-Key": <admin_key>}
-
- Body (application/json)
-
+ Body (application/json)
Returns 200 OK (application/json)
<jukbox_object>
Curl example
- curl -X POST {{ request.url_root }}api/v1/jukebox/ -d
- '{"user": <string, user_id>,
- "title": <string>, "wallet":<string>, "sp_user":
- <string, spotify_user_account>, "sp_secret": <string, spotify_user_secret>, "sp_access_token":
- <string, not_required>, "sp_refresh_token":
- <string, not_required>, "sp_device": <string, spotify_user_secret>, "sp_playlists":
- <string, not_required>, "price":
- <integer, not_required>}' -H "Content-type:
- application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
+ curl -X POST {{ request.base_url }}api/v1/jukebox/ -d '{"user":
+ <string, user_id>, "title": <string>,
+ "wallet":<string>, "sp_user": <string,
+ spotify_user_account>, "sp_secret": <string,
+ spotify_user_secret>, "sp_access_token": <string,
+ not_required>, "sp_refresh_token": <string, not_required>,
+ "sp_device": <string, spotify_user_secret>, "sp_playlists":
+ <string, not_required>, "price": <integer, not_required>}'
+ -H "Content-type: application/json" -H "X-Api-Key:
+ {{user.wallets[0].adminkey }}"
@@ -83,8 +103,10 @@
- DELETE
- /jukebox/api/v1/jukebox/<juke_id>
+ DELETE
+ /jukebox/api/v1/jukebox/<juke_id>
Headers
{"X-Api-Key": <admin_key>}
Body (application/json)
@@ -93,9 +115,11 @@
<jukebox_object>
Curl example
- curl -X DELETE {{ request.url_root }}api/v1/jukebox/<juke_id> -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
+ curl -X DELETE {{ request.base_url }}api/v1/jukebox/<juke_id>
+ -H "X-Api-Key: {{ user.wallets[0].adminkey }}"
-
\ No newline at end of file
+
diff --git a/lnbits/extensions/jukebox/templates/jukebox/error.html b/lnbits/extensions/jukebox/templates/jukebox/error.html
index 746e403c..f6f7fd58 100644
--- a/lnbits/extensions/jukebox/templates/jukebox/error.html
+++ b/lnbits/extensions/jukebox/templates/jukebox/error.html
@@ -12,7 +12,9 @@
style="font-size: 20rem"
>
- Ask the host to turn on the device and launch spotify
+
+ Ask the host to turn on the device and launch spotify
+
diff --git a/lnbits/extensions/jukebox/templates/jukebox/index.html b/lnbits/extensions/jukebox/templates/jukebox/index.html
index 25cc49e5..9b4efbd5 100644
--- a/lnbits/extensions/jukebox/templates/jukebox/index.html
+++ b/lnbits/extensions/jukebox/templates/jukebox/index.html
@@ -4,18 +4,36 @@
- Add Spotify Jukebox
+ Add Spotify Jukebox
{% raw %}
-
+
-
+
{{ col.label }}
@@ -26,18 +44,43 @@
-
+
Jukebox QR
-
-
+
+
Delete Jukebox
-
+
{{ col.value }}
@@ -52,7 +95,9 @@
- LNbits jukebox extension
+
+ {{SITE_TITLE}} jukebox extension
+
@@ -63,23 +108,62 @@
-
-
-
-
-
+
+
+
+
+
Continue
- Continue
+ color="primary"
+ @click="step = 2"
+ >Continue
+ Continue
- Cancel
+ Cancel
@@ -90,26 +174,57 @@
To use this extension you need a Spotify client ID and client secret.
You get these by creating an app in the Spotify developers dashboard
- here.
-
+ here.
+
-
+
-
+
- Submit keys
- Submit keys
+ Submit keys
+ Submit keys
- Cancel
+ Cancel
@@ -120,42 +235,93 @@
In the app go to edit-settings, set the redirect URI to this link
- {% raw %}{{ locationcb
- }}{{ jukeboxDialog.data.sp_id }}{% endraw
+ {% raw %}{{ locationcb }}{{ jukeboxDialog.data.sp_id }}{% endraw
%} Click to copy URL
Settings can be found
- here.
+ here.
- Authorise access
- Authorise access
+ Authorise access
+ Authorise access
- Cancel
+ Cancel
-
-
-
+
+
+
- Create Jukebox
- Create Jukebox
+ Create Jukebox
+ Create Jukebox
- Cancel
+ Cancel
@@ -169,15 +335,28 @@
Shareable Jukebox QR
-
+
-
- Copy jukebox link
- Open jukebox
+
+ Copy jukebox link
+ Open jukebox
Close
@@ -186,4 +365,4 @@
{% endblock %} {% block scripts %} {{ window_vars(user) }}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/lnbits/extensions/jukebox/templates/jukebox/jukebox.html b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html
index 7eeb5de3..2791f09e 100644
--- a/lnbits/extensions/jukebox/templates/jukebox/jukebox.html
+++ b/lnbits/extensions/jukebox/templates/jukebox/jukebox.html
@@ -1,4 +1,4 @@
-{% extends "public.html" %} {% block page %} {% raw %}
+{% extends "public.html" %} {% block page %}
@@ -9,9 +9,12 @@
- {{ currentPlay.name }}
+ {% raw %}
+ {{ currentPlay.name }}
{{ currentPlay.artist }}
+ {% endraw %}
@@ -19,18 +22,33 @@
Pick a song
-
+
-
+
-
+
- {{ item.name }} - ({{ item.artist }})
+ {% raw %} {{ item.name }} - ({{ item.artist }}){% endraw %}
@@ -38,42 +56,48 @@
-
-
-
-
-
-
-
-
-
- {{ receive.name }}
- {{ receive.artist }}
+
+
+
+
+
+
+
+
+ {% raw %}
+ {{ receive.name }}
+ {{ receive.artist }}
+
+
+
+
+ Play for {% endraw %}{{ price }}sats
+
-
-
-
- Play for {% endraw %}{{ price }}{% raw %} sats
-
-
-
-
-
-
-
-
-
-
- Copy invoice
-
-
-
+
+
+
+
+
+
+
+
+ Copy invoice
+
+
+
+
-{% endraw %} {% endblock %} {% block scripts %}
-
-
+{% endblock %} {% block scripts %}
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/lnbits/extensions/jukebox/views.py b/lnbits/extensions/jukebox/views.py
index f439110a..56774394 100644
--- a/lnbits/extensions/jukebox/views.py
+++ b/lnbits/extensions/jukebox/views.py
@@ -1,42 +1,52 @@
-import time
-from datetime import datetime
-from quart import g, render_template, request, jsonify, websocket
from http import HTTPStatus
-import trio
-from lnbits.decorators import check_user_exists, validate_uuids
-from lnbits.core.models import Payment
-import json
-from . import jukebox_ext
+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 jukebox_ext, jukebox_renderer
from .crud import get_jukebox
from .views_api import api_get_jukebox_device_check
-
-@jukebox_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("jukebox/index.html", user=g.user)
+templates = Jinja2Templates(directory="templates")
-@jukebox_ext.route("/")
-async def connect_to_jukebox(juke_id):
+@jukebox_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return jukebox_renderer().TemplateResponse(
+ "jukebox/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@jukebox_ext.get("/{juke_id}", response_class=HTMLResponse)
+async def connect_to_jukebox(request: Request, juke_id):
jukebox = await get_jukebox(juke_id)
if not jukebox:
- return "error"
- deviceCheck = await api_get_jukebox_device_check(juke_id)
- devices = json.loads(deviceCheck[0].text)
- deviceConnected = False
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist."
+ )
+ devices = await api_get_jukebox_device_check(juke_id)
for device in devices["devices"]:
if device["id"] == jukebox.sp_device.split("-")[1]:
deviceConnected = True
if deviceConnected:
- return await render_template(
+ return jukebox_renderer().TemplateResponse(
"jukebox/jukebox.html",
- playlists=jukebox.sp_playlists.split(","),
- juke_id=juke_id,
- price=jukebox.price,
- inkey=jukebox.inkey,
+ {
+ "request": request,
+ "playlists": jukebox.sp_playlists.split(","),
+ "juke_id": juke_id,
+ "price": jukebox.price,
+ "inkey": jukebox.inkey,
+ },
)
else:
- return await render_template("jukebox/error.html")
+ return jukebox_renderer().TemplateResponse(
+ "jukebox/error.html",
+ {"request": request, "jukebox": jukebox.jukebox(req=request)},
+ )
diff --git a/lnbits/extensions/jukebox/views_api.py b/lnbits/extensions/jukebox/views_api.py
index 4bd1fb40..3ba8cbf2 100644
--- a/lnbits/extensions/jukebox/views_api.py
+++ b/lnbits/extensions/jukebox/views_api.py
@@ -1,118 +1,106 @@
-from quart import g, jsonify, request
-from http import HTTPStatus
import base64
-from lnbits.core.crud import get_wallet
-from lnbits.core.services import create_invoice, check_invoice_status
import json
+from http import HTTPStatus
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
import httpx
+from fastapi import Request
+from fastapi.param_functions import Query
+from fastapi.params import Depends
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse # type: ignore
+
+from lnbits.core.services import create_invoice
+from lnbits.core.views.api import api_payment
+from lnbits.decorators import WalletTypeInfo, require_admin_key
+
from . import jukebox_ext
from .crud import (
create_jukebox,
- update_jukebox,
- get_jukebox,
- get_jukeboxs,
- delete_jukebox,
create_jukebox_payment,
+ delete_jukebox,
+ get_jukebox,
get_jukebox_payment,
+ get_jukeboxs,
+ update_jukebox,
update_jukebox_payment,
)
-from lnbits.core.services import create_invoice, check_invoice_status
+from .models import CreateJukeboxPayment, CreateJukeLinkData
-@jukebox_ext.route("/api/v1/jukebox", methods=["GET"])
-@api_check_wallet_key("admin")
-async def api_get_jukeboxs():
+@jukebox_ext.get("/api/v1/jukebox")
+async def api_get_jukeboxs(
+ req: Request,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+ all_wallets: bool = Query(False),
+):
+ wallet_user = wallet.wallet.user
+
try:
- return (
- jsonify(
- [{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)]
- ),
- HTTPStatus.OK,
- )
+ jukeboxs = [jukebox.dict() for jukebox in await get_jukeboxs(wallet_user)]
+ return jukeboxs
+
except:
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukeboxes")
##################SPOTIFY AUTH#####################
-@jukebox_ext.route("/api/v1/jukebox/spotify/cb/", methods=["GET"])
-async def api_check_credentials_callbac(juke_id):
+@jukebox_ext.get("/api/v1/jukebox/spotify/cb/{juke_id}", response_class=HTMLResponse)
+async def api_check_credentials_callbac(
+ juke_id: str = Query(None),
+ code: str = Query(None),
+ access_token: str = Query(None),
+ refresh_token: str = Query(None),
+):
sp_code = ""
sp_access_token = ""
sp_refresh_token = ""
try:
jukebox = await get_jukebox(juke_id)
except:
- return (
- jsonify({"error": "No Jukebox"}),
- HTTPStatus.FORBIDDEN,
- )
- if request.args.get("code"):
- sp_code = request.args.get("code")
- jukebox = await update_jukebox(
- juke_id=juke_id, sp_secret=jukebox.sp_secret, sp_access_token=sp_code
- )
- if request.args.get("access_token"):
- sp_access_token = request.args.get("access_token")
- sp_refresh_token = request.args.get("refresh_token")
- jukebox = await update_jukebox(
- juke_id=juke_id,
- sp_secret=jukebox.sp_secret,
- sp_access_token=sp_access_token,
- sp_refresh_token=sp_refresh_token,
- )
+ raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN)
+ if code:
+ jukebox.sp_access_token = code
+ jukebox = await update_jukebox(jukebox, juke_id=juke_id)
+ if access_token:
+ jukebox.sp_access_token = access_token
+ jukebox.sp_refresh_token = refresh_token
+ jukebox = await update_jukebox(jukebox, juke_id=juke_id)
return "Success!
You can close this window
"
-@jukebox_ext.route("/api/v1/jukebox/", methods=["GET"])
-@api_check_wallet_key("admin")
-async def api_check_credentials_check(juke_id):
+@jukebox_ext.get("/api/v1/jukebox/{juke_id}")
+async def api_check_credentials_check(
+ juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
+):
jukebox = await get_jukebox(juke_id)
- return jsonify(jukebox._asdict()), HTTPStatus.CREATED
+ return jukebox
-@jukebox_ext.route("/api/v1/jukebox/", methods=["POST"])
-@jukebox_ext.route("/api/v1/jukebox/", methods=["PUT"])
-@api_check_wallet_key("admin")
-@api_validate_post_request(
- schema={
- "user": {"type": "string", "empty": False, "required": True},
- "title": {"type": "string", "empty": False, "required": True},
- "wallet": {"type": "string", "empty": False, "required": True},
- "sp_user": {"type": "string", "empty": False, "required": True},
- "sp_secret": {"type": "string", "required": True},
- "sp_access_token": {"type": "string", "required": False},
- "sp_refresh_token": {"type": "string", "required": False},
- "sp_device": {"type": "string", "required": False},
- "sp_playlists": {"type": "string", "required": False},
- "price": {"type": "string", "required": False},
- }
-)
-async def api_create_update_jukebox(juke_id=None):
+@jukebox_ext.post("/api/v1/jukebox", status_code=HTTPStatus.CREATED)
+@jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK)
+async def api_create_update_jukebox(
+ data: CreateJukeLinkData,
+ juke_id: str = Query(None),
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+):
if juke_id:
- jukebox = await update_jukebox(juke_id=juke_id, inkey=g.wallet.inkey, **g.data)
+ jukebox = await update_jukebox(data, juke_id=juke_id)
else:
- jukebox = await create_jukebox(inkey=g.wallet.inkey, **g.data)
-
- return jsonify(jukebox._asdict()), HTTPStatus.CREATED
+ jukebox = await create_jukebox(data, inkey=wallet.wallet.inkey)
+ return jukebox
-@jukebox_ext.route("/api/v1/jukebox/", methods=["DELETE"])
-@api_check_wallet_key("admin")
-async def api_delete_item(juke_id):
+@jukebox_ext.delete("/api/v1/jukebox/{juke_id}")
+async def api_delete_item(
+ juke_id=None, wallet: WalletTypeInfo = Depends(require_admin_key)
+):
await delete_jukebox(juke_id)
try:
- return (
- jsonify(
- [{**jukebox._asdict()} for jukebox in await get_jukeboxs(g.wallet.user)]
- ),
- HTTPStatus.OK,
- )
+ return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
except:
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
################JUKEBOX ENDPOINTS##################
@@ -120,17 +108,16 @@ async def api_delete_item(juke_id):
######GET ACCESS TOKEN######
-@jukebox_ext.route(
- "/api/v1/jukebox/jb/playlist//", methods=["GET"]
-)
-async def api_get_jukebox_song(juke_id, sp_playlist, retry=False):
+@jukebox_ext.get("/api/v1/jukebox/jb/playlist/{juke_id}/{sp_playlist}")
+async def api_get_jukebox_song(
+ juke_id: str = Query(None),
+ sp_playlist: str = Query(None),
+ retry: bool = Query(False),
+):
try:
jukebox = await get_jukebox(juke_id)
except:
- return (
- jsonify({"error": "No Jukebox"}),
- HTTPStatus.FORBIDDEN,
- )
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
tracks = []
async with httpx.AsyncClient() as client:
try:
@@ -145,15 +132,15 @@ async def api_get_jukebox_song(juke_id, sp_playlist, retry=False):
if token == False:
return False
elif retry:
- return (
- jsonify({"error": "Failed to get auth"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Failed to get auth",
)
else:
return await api_get_jukebox_song(
juke_id, sp_playlist, retry=True
)
- return r, HTTPStatus.OK
+ return r
for item in r.json()["items"]:
tracks.append(
{
@@ -164,19 +151,16 @@ async def api_get_jukebox_song(juke_id, sp_playlist, retry=False):
"image": item["track"]["album"]["images"][0]["url"],
}
)
- except AssertionError:
+ except:
something = None
- return jsonify([track for track in tracks])
+ return [track for track in tracks]
-async def api_get_token(juke_id):
+async def api_get_token(juke_id=None):
try:
jukebox = await get_jukebox(juke_id)
except:
- return (
- jsonify({"error": "No Jukebox"}),
- HTTPStatus.FORBIDDEN,
- )
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
async with httpx.AsyncClient() as client:
try:
@@ -200,10 +184,9 @@ async def api_get_token(juke_id):
if "access_token" not in r.json():
return False
else:
- await update_jukebox(
- juke_id=juke_id, sp_access_token=r.json()["access_token"]
- )
- except AssertionError:
+ jukebox.sp_access_token = r.json()["access_token"]
+ await update_jukebox(jukebox, juke_id=juke_id)
+ except:
something = None
return True
@@ -211,76 +194,64 @@ async def api_get_token(juke_id):
######CHECK DEVICE
-@jukebox_ext.route("/api/v1/jukebox/jb/", methods=["GET"])
-async def api_get_jukebox_device_check(juke_id, retry=False):
+@jukebox_ext.get("/api/v1/jukebox/jb/{juke_id}")
+async def api_get_jukebox_device_check(
+ juke_id: str = Query(None), retry: bool = Query(False)
+):
try:
jukebox = await get_jukebox(juke_id)
except:
- return (
- jsonify({"error": "No Jukebox"}),
- HTTPStatus.FORBIDDEN,
- )
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
async with httpx.AsyncClient() as client:
rDevice = await client.get(
"https://api.spotify.com/v1/me/player/devices",
timeout=40,
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
-
if rDevice.status_code == 204 or rDevice.status_code == 200:
- return (
- rDevice,
- HTTPStatus.OK,
- )
+ return json.loads(rDevice.text)
elif rDevice.status_code == 401 or rDevice.status_code == 403:
token = await api_get_token(juke_id)
if token == False:
- return (
- jsonify({"error": "No device connected"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="No devices connected"
)
elif retry:
- return (
- jsonify({"error": "Failed to get auth"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
)
else:
return api_get_jukebox_device_check(juke_id, retry=True)
else:
- return (
- jsonify({"error": "No device connected"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="No device connected"
)
######GET INVOICE STUFF
-@jukebox_ext.route("/api/v1/jukebox/jb/invoice//", methods=["GET"])
+@jukebox_ext.get("/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}")
async def api_get_jukebox_invoice(juke_id, song_id):
try:
jukebox = await get_jukebox(juke_id)
+
except:
- return (
- jsonify({"error": "No Jukebox"}),
- HTTPStatus.FORBIDDEN,
- )
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
try:
- deviceCheck = await api_get_jukebox_device_check(juke_id)
- devices = json.loads(deviceCheck[0].text)
+
+ devices = await api_get_jukebox_device_check(juke_id)
deviceConnected = False
for device in devices["devices"]:
if device["id"] == jukebox.sp_device.split("-")[1]:
deviceConnected = True
if not deviceConnected:
- return (
- jsonify({"error": "No device connected"}),
- HTTPStatus.NOT_FOUND,
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="No device connected"
)
except:
- return (
- jsonify({"error": "No device connected"}),
- HTTPStatus.NOT_FOUND,
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="No device connected"
)
invoice = await create_invoice(
@@ -290,47 +261,46 @@ async def api_get_jukebox_invoice(juke_id, song_id):
extra={"tag": "jukebox"},
)
- jukebox_payment = await create_jukebox_payment(song_id, invoice[0], juke_id)
+ payment_hash = invoice[0]
+ data = CreateJukeboxPayment(
+ invoice=invoice[1], payment_hash=payment_hash, juke_id=juke_id, song_id=song_id
+ )
+ jukebox_payment = await create_jukebox_payment(data)
- return jsonify(invoice, jukebox_payment)
+ return data
-@jukebox_ext.route(
- "/api/v1/jukebox/jb/checkinvoice//", methods=["GET"]
-)
-async def api_get_jukebox_invoice_check(pay_hash, juke_id):
+@jukebox_ext.get("/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}")
+async def api_get_jukebox_invoice_check(
+ pay_hash: str = Query(None), juke_id: str = Query(None)
+):
+ try:
+ await get_jukebox(juke_id)
+ except:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
+ try:
+ status = await api_payment(pay_hash)
+ if status["paid"]:
+ await update_jukebox_payment(pay_hash, paid=True)
+ return {"paid": True}
+ except:
+ return {"paid": False}
+
+ return {"paid": False}
+
+
+@jukebox_ext.get("/api/v1/jukebox/jb/invoicep/{song_id}/{juke_id}/{pay_hash}")
+async def api_get_jukebox_invoice_paid(
+ song_id: str = Query(None),
+ juke_id: str = Query(None),
+ pay_hash: str = Query(None),
+ retry: bool = Query(False),
+):
try:
jukebox = await get_jukebox(juke_id)
except:
- return (
- jsonify({"error": "No Jukebox"}),
- HTTPStatus.FORBIDDEN,
- )
- try:
- status = await check_invoice_status(jukebox.wallet, pay_hash)
- is_paid = not status.pending
- except Exception as exc:
- return jsonify({"paid": False}), HTTPStatus.OK
- if is_paid:
- wallet = await get_wallet(jukebox.wallet)
- payment = await wallet.get_payment(pay_hash)
- await payment.set_pending(False)
- await update_jukebox_payment(pay_hash, paid=True)
- return jsonify({"paid": True}), HTTPStatus.OK
- return jsonify({"paid": False}), HTTPStatus.OK
-
-
-@jukebox_ext.route(
- "/api/v1/jukebox/jb/invoicep///", methods=["GET"]
-)
-async def api_get_jukebox_invoice_paid(song_id, juke_id, pay_hash, retry=False):
- try:
- jukebox = await get_jukebox(juke_id)
- except:
- return (
- jsonify({"error": "No Jukebox"}),
- HTTPStatus.FORBIDDEN,
- )
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
+ await api_get_jukebox_invoice_check(pay_hash, juke_id)
jukebox_payment = await get_jukebox_payment(pay_hash)
if jukebox_payment.paid:
async with httpx.AsyncClient() as client:
@@ -359,27 +329,26 @@ async def api_get_jukebox_invoice_paid(song_id, juke_id, pay_hash, retry=False):
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
if r.status_code == 204:
- return jsonify(jukebox_payment), HTTPStatus.OK
+ return jukebox_payment
elif r.status_code == 401 or r.status_code == 403:
token = await api_get_token(juke_id)
if token == False:
- return (
- jsonify({"error": "Invoice not paid"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Invoice not paid",
)
elif retry:
- return (
- jsonify({"error": "Failed to get auth"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Failed to get auth",
)
else:
return api_get_jukebox_invoice_paid(
song_id, juke_id, pay_hash, retry=True
)
else:
- return (
- jsonify({"error": "Invoice not paid"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Invoice not paid"
)
elif r.status_code == 200:
async with httpx.AsyncClient() as client:
@@ -392,60 +361,56 @@ async def api_get_jukebox_invoice_paid(song_id, juke_id, pay_hash, retry=False):
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
if r.status_code == 204:
- return jsonify(jukebox_payment), HTTPStatus.OK
+ return jukebox_payment
elif r.status_code == 401 or r.status_code == 403:
token = await api_get_token(juke_id)
if token == False:
- return (
- jsonify({"error": "Invoice not paid"}),
- HTTPStatus.OK,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Invoice not paid",
)
elif retry:
- return (
- jsonify({"error": "Failed to get auth"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Failed to get auth",
)
else:
return await api_get_jukebox_invoice_paid(
song_id, juke_id, pay_hash
)
else:
- return (
- jsonify({"error": "Invoice not paid"}),
- HTTPStatus.OK,
+ raise HTTPException(
+ status_code=HTTPStatus.OK, detail="Invoice not paid"
)
elif r.status_code == 401 or r.status_code == 403:
token = await api_get_token(juke_id)
if token == False:
- return (
- jsonify({"error": "Invoice not paid"}),
- HTTPStatus.OK,
+ raise HTTPException(
+ status_code=HTTPStatus.OK, detail="Invoice not paid"
)
elif retry:
- return (
- jsonify({"error": "Failed to get auth"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
)
else:
return await api_get_jukebox_invoice_paid(
song_id, juke_id, pay_hash
)
- return jsonify({"error": "Invoice not paid"}), HTTPStatus.OK
+ raise HTTPException(status_code=HTTPStatus.OK, detail="Invoice not paid")
############################GET TRACKS
-@jukebox_ext.route("/api/v1/jukebox/jb/currently/", methods=["GET"])
-async def api_get_jukebox_currently(juke_id, retry=False):
+@jukebox_ext.get("/api/v1/jukebox/jb/currently/{juke_id}")
+async def api_get_jukebox_currently(
+ retry: bool = Query(False), juke_id: str = Query(None)
+):
try:
jukebox = await get_jukebox(juke_id)
except:
- return (
- jsonify({"error": "No Jukebox"}),
- HTTPStatus.FORBIDDEN,
- )
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
async with httpx.AsyncClient() as client:
try:
r = await client.get(
@@ -454,7 +419,7 @@ async def api_get_jukebox_currently(juke_id, retry=False):
headers={"Authorization": "Bearer " + jukebox.sp_access_token},
)
if r.status_code == 204:
- return jsonify({"error": "Nothing"}), HTTPStatus.OK
+ raise HTTPException(status_code=HTTPStatus.OK, detail="Nothing")
elif r.status_code == 200:
try:
response = r.json()
@@ -466,25 +431,29 @@ async def api_get_jukebox_currently(juke_id, retry=False):
"artist": response["item"]["artists"][0]["name"],
"image": response["item"]["album"]["images"][0]["url"],
}
- return jsonify(track), HTTPStatus.OK
+ return track
except:
- return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong"
+ )
elif r.status_code == 401:
token = await api_get_token(juke_id)
if token == False:
- return (
- jsonify({"error": "Invoice not paid"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Invoice not paid"
)
elif retry:
- return (
- jsonify({"error": "Failed to get auth"}),
- HTTPStatus.FORBIDDEN,
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
)
else:
- return await api_get_jukebox_currently(juke_id, retry=True)
+ return await api_get_jukebox_currently(retry=True, juke_id=juke_id)
else:
- return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND
- except AssertionError:
- return jsonify("Something went wrong"), HTTPStatus.NOT_FOUND
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong"
+ )
+ except:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Something went wrong, or no song is playing yet"
+ )
diff --git a/lnbits/extensions/livestream/__init__.py b/lnbits/extensions/livestream/__init__.py
index d8f61fe0..17fc2d3b 100644
--- a/lnbits/extensions/livestream/__init__.py
+++ b/lnbits/extensions/livestream/__init__.py
@@ -1,19 +1,35 @@
-from quart import Blueprint
+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_livestream")
-livestream_ext: Blueprint = Blueprint(
- "livestream", __name__, static_folder="static", template_folder="templates"
-)
+livestream_static_files = [
+ {
+ "path": "/livestream/static",
+ "app": StaticFiles(directory="lnbits/extensions/livestream/static"),
+ "name": "livestream_static",
+ }
+]
+
+livestream_ext: APIRouter = APIRouter(prefix="/livestream", tags=["livestream"])
+
+
+def livestream_renderer():
+ return template_renderer(["lnbits/extensions/livestream/templates"])
-from .views_api import * # noqa
-from .views import * # noqa
from .lnurl import * # noqa
-from .tasks import register_listeners
+from .tasks import wait_for_paid_invoices
+from .views import * # noqa
+from .views_api import * # noqa
-from lnbits.tasks import record_async
-livestream_ext.record(record_async(register_listeners))
+def livestream_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/livestream/crud.py b/lnbits/extensions/livestream/crud.py
index 1c1043db..4784494c 100644
--- a/lnbits/extensions/livestream/crud.py
+++ b/lnbits/extensions/livestream/crud.py
@@ -1,32 +1,43 @@
from typing import List, Optional
from lnbits.core.crud import create_account, create_wallet
+from lnbits.db import SQLITE
from . import db
-from .models import Livestream, Track, Producer
+from .models import Livestream, Producer, Track
async def create_livestream(*, wallet_id: str) -> int:
- result = await db.execute(
- """
- INSERT INTO livestreams (wallet)
+ returning = "" if db.type == SQLITE else "RETURNING ID"
+ method = db.execute if db.type == SQLITE else db.fetchone
+
+ result = await (method)(
+ f"""
+ INSERT INTO livestream.livestreams (wallet)
VALUES (?)
+ {returning}
""",
(wallet_id,),
)
- return result._result_proxy.lastrowid
+
+ if db.type == SQLITE:
+ return result._result_proxy.lastrowid
+ else:
+ return result[0]
async def get_livestream(ls_id: int) -> Optional[Livestream]:
- row = await db.fetchone("SELECT * FROM livestreams WHERE id = ?", (ls_id,))
+ row = await db.fetchone(
+ "SELECT * FROM livestream.livestreams WHERE id = ?", (ls_id,)
+ )
return Livestream(**dict(row)) if row else None
async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
row = await db.fetchone(
"""
- SELECT livestreams.* FROM livestreams
- INNER JOIN tracks ON tracks.livestream = livestreams.id
+ SELECT livestreams.* AS livestreams FROM livestream.livestreams
+ INNER JOIN livestream.tracks AS tracks ON tracks.livestream = livestreams.id
WHERE tracks.id = ?
""",
(track_id,),
@@ -35,7 +46,9 @@ async def get_livestream_by_track(track_id: int) -> Optional[Livestream]:
async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream]:
- row = await db.fetchone("SELECT * FROM livestreams WHERE wallet = ?", (wallet,))
+ row = await db.fetchone(
+ "SELECT * FROM livestream.livestreams WHERE wallet = ?", (wallet,)
+ )
if not row:
# create on the fly
@@ -47,15 +60,14 @@ async def get_or_create_livestream_by_wallet(wallet: str) -> Optional[Livestream
async def update_current_track(ls_id: int, track_id: Optional[int]):
await db.execute(
- "UPDATE livestreams SET current_track = ? WHERE id = ?",
+ "UPDATE livestream.livestreams SET current_track = ? WHERE id = ?",
(track_id, ls_id),
)
async def update_livestream_fee(ls_id: int, fee_pct: int):
await db.execute(
- "UPDATE livestreams SET fee_pct = ? WHERE id = ?",
- (fee_pct, ls_id),
+ "UPDATE livestream.livestreams SET fee_pct = ? WHERE id = ?", (fee_pct, ls_id)
)
@@ -68,7 +80,7 @@ async def add_track(
) -> int:
result = await db.execute(
"""
- INSERT INTO tracks (livestream, name, download_url, price_msat, producer)
+ INSERT INTO livestream.tracks (livestream, name, download_url, price_msat, producer)
VALUES (?, ?, ?, ?, ?)
""",
(livestream, name, download_url, price_msat, producer),
@@ -86,7 +98,7 @@ async def update_track(
) -> int:
result = await db.execute(
"""
- UPDATE tracks SET
+ UPDATE livestream.tracks SET
name = ?,
download_url = ?,
price_msat = ?,
@@ -105,7 +117,7 @@ async def get_track(track_id: Optional[int]) -> Optional[Track]:
row = await db.fetchone(
"""
SELECT id, download_url, price_msat, name, producer
- FROM tracks WHERE id = ?
+ FROM livestream.tracks WHERE id = ?
""",
(track_id,),
)
@@ -116,7 +128,7 @@ async def get_tracks(livestream: int) -> List[Track]:
rows = await db.fetchall(
"""
SELECT id, download_url, price_msat, name, producer
- FROM tracks WHERE livestream = ?
+ FROM livestream.tracks WHERE livestream = ?
""",
(livestream,),
)
@@ -126,7 +138,7 @@ async def get_tracks(livestream: int) -> List[Track]:
async def delete_track_from_livestream(livestream: int, track_id: int):
await db.execute(
"""
- DELETE FROM tracks WHERE livestream = ? AND id = ?
+ DELETE FROM livestream.tracks WHERE livestream = ? AND id = ?
""",
(livestream, track_id),
)
@@ -137,7 +149,7 @@ async def add_producer(livestream: int, name: str) -> int:
existing = await db.fetchall(
"""
- SELECT id FROM producers
+ SELECT id FROM livestream.producers
WHERE livestream = ? AND lower(name) = ?
""",
(livestream, name.lower()),
@@ -148,21 +160,28 @@ async def add_producer(livestream: int, name: str) -> int:
user = await create_account()
wallet = await create_wallet(user_id=user.id, wallet_name="livestream: " + name)
- result = await db.execute(
- """
- INSERT INTO producers (livestream, name, user, wallet)
+ returning = "" if db.type == SQLITE else "RETURNING ID"
+ method = db.execute if db.type == SQLITE else db.fetchone
+
+ result = await method(
+ f"""
+ INSERT INTO livestream.producers (livestream, name, "user", wallet)
VALUES (?, ?, ?, ?)
+ {returning}
""",
(livestream, name, user.id, wallet.id),
)
- return result._result_proxy.lastrowid
+ if db.type == SQLITE:
+ return result._result_proxy.lastrowid
+ else:
+ return result[0]
async def get_producer(producer_id: int) -> Optional[Producer]:
row = await db.fetchone(
"""
- SELECT id, user, wallet, name
- FROM producers WHERE id = ?
+ SELECT id, "user", wallet, name
+ FROM livestream.producers WHERE id = ?
""",
(producer_id,),
)
@@ -172,8 +191,8 @@ async def get_producer(producer_id: int) -> Optional[Producer]:
async def get_producers(livestream: int) -> List[Producer]:
rows = await db.fetchall(
"""
- SELECT id, user, wallet, name
- FROM producers WHERE livestream = ?
+ SELECT id, "user", wallet, name
+ FROM livestream.producers WHERE livestream = ?
""",
(livestream,),
)
diff --git a/lnbits/extensions/livestream/lnurl.py b/lnbits/extensions/livestream/lnurl.py
index 3b9e7e31..861955de 100644
--- a/lnbits/extensions/livestream/lnurl.py
+++ b/lnbits/extensions/livestream/lnurl.py
@@ -1,7 +1,12 @@
import hashlib
import math
-from quart import jsonify, url_for, request
-from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
+from http import HTTPStatus
+from os import name
+
+from fastapi.exceptions import HTTPException
+from fastapi.params import Query
+from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
+from starlette.requests import Request # type: ignore
from lnbits.core.services import create_invoice
@@ -9,20 +14,22 @@ from . import livestream_ext
from .crud import get_livestream, get_livestream_by_track, get_track
-@livestream_ext.route("/lnurl/", methods=["GET"])
-async def lnurl_livestream(ls_id):
+@livestream_ext.get("/lnurl/{ls_id}", name="livestream.lnurl_livestream")
+async def lnurl_livestream(ls_id, request: Request):
ls = await get_livestream(ls_id)
if not ls:
- return jsonify({"status": "ERROR", "reason": "Livestream not found."})
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Livestream not found."
+ )
track = await get_track(ls.current_track)
if not track:
- return jsonify({"status": "ERROR", "reason": "This livestream is offline."})
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="This livestream is offline."
+ )
resp = LnurlPayResponse(
- callback=url_for(
- "livestream.lnurl_callback", track_id=track.id, _external=True
- ),
+ callback=request.url_for("livestream.lnurl_callback", track_id=track.id),
min_sendable=track.min_sendable,
max_sendable=track.max_sendable,
metadata=await track.lnurlpay_metadata(),
@@ -31,19 +38,17 @@ async def lnurl_livestream(ls_id):
params = resp.dict()
params["commentAllowed"] = 300
- return jsonify(params)
+ return params
-@livestream_ext.route("/lnurl/t/", methods=["GET"])
-async def lnurl_track(track_id):
+@livestream_ext.get("/lnurl/t/{track_id}", name="livestream.lnurl_track")
+async def lnurl_track(track_id, request: Request):
track = await get_track(track_id)
if not track:
- return jsonify({"status": "ERROR", "reason": "Track not found."})
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Track not found.")
resp = LnurlPayResponse(
- callback=url_for(
- "livestream.lnurl_callback", track_id=track.id, _external=True
- ),
+ callback=request.url_for("livestream.lnurl_callback", track_id=track.id),
min_sendable=track.min_sendable,
max_sendable=track.max_sendable,
metadata=await track.lnurlpay_metadata(),
@@ -52,41 +57,32 @@ async def lnurl_track(track_id):
params = resp.dict()
params["commentAllowed"] = 300
- return jsonify(params)
+ return params
-@livestream_ext.route("/lnurl/cb/", methods=["GET"])
-async def lnurl_callback(track_id):
+@livestream_ext.get("/lnurl/cb/{track_id}", name="livestream.lnurl_callback")
+async def lnurl_callback(
+ track_id, request: Request, amount: int = Query(...), comment: str = Query("")
+):
track = await get_track(track_id)
if not track:
- return jsonify({"status": "ERROR", "reason": "Couldn't find track."})
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Track not found.")
- amount_received = int(request.args.get("amount") or 0)
+ amount_received = int(amount or 0)
if amount_received < track.min_sendable:
- return (
- jsonify(
- LnurlErrorResponse(
- reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}."
- ).dict()
- ),
- )
+ return LnurlErrorResponse(
+ reason=f"Amount {round(amount_received / 1000)} is smaller than minimum {math.floor(track.min_sendable)}."
+ ).dict()
elif track.max_sendable < amount_received:
- return (
- jsonify(
- LnurlErrorResponse(
- reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}."
- ).dict()
- ),
- )
+ return LnurlErrorResponse(
+ reason=f"Amount {round(amount_received / 1000)} is greater than maximum {math.floor(track.max_sendable)}."
+ ).dict()
- comment = request.args.get("comment")
if len(comment or "") > 300:
- return jsonify(
- LnurlErrorResponse(
- reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
- ).dict()
- )
+ return LnurlErrorResponse(
+ reason=f"Got a comment with {len(comment)} characters, but can only accept 300"
+ ).dict()
ls = await get_livestream_by_track(track_id)
@@ -103,12 +99,10 @@ async def lnurl_callback(track_id):
if amount_received < track.price_msat:
success_action = None
else:
- success_action = track.success_action(payment_hash)
+ success_action = track.success_action(payment_hash, request=request)
resp = LnurlPayActionResponse(
- pr=payment_request,
- success_action=success_action,
- routes=[],
+ pr=payment_request, success_action=success_action, routes=[]
)
- return jsonify(resp.dict())
+ return resp.dict()
diff --git a/lnbits/extensions/livestream/migrations.py b/lnbits/extensions/livestream/migrations.py
index 9fb01d30..fb664ab1 100644
--- a/lnbits/extensions/livestream/migrations.py
+++ b/lnbits/extensions/livestream/migrations.py
@@ -3,9 +3,9 @@ async def m001_initial(db):
Initial livestream tables.
"""
await db.execute(
- """
- CREATE TABLE livestreams (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
+ f"""
+ CREATE TABLE livestream.livestreams (
+ id {db.serial_primary_key},
wallet TEXT NOT NULL,
fee_pct INTEGER NOT NULL DEFAULT 10,
current_track INTEGER
@@ -14,11 +14,11 @@ async def m001_initial(db):
)
await db.execute(
- """
- CREATE TABLE producers (
- livestream INTEGER NOT NULL REFERENCES livestreams (id),
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- user TEXT NOT NULL,
+ f"""
+ CREATE TABLE livestream.producers (
+ livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id),
+ id {db.serial_primary_key},
+ "user" TEXT NOT NULL,
wallet TEXT NOT NULL,
name TEXT NOT NULL
);
@@ -26,14 +26,14 @@ async def m001_initial(db):
)
await db.execute(
- """
- CREATE TABLE tracks (
- livestream INTEGER NOT NULL REFERENCES livestreams (id),
- id INTEGER PRIMARY KEY AUTOINCREMENT,
+ f"""
+ CREATE TABLE livestream.tracks (
+ livestream INTEGER NOT NULL REFERENCES {db.references_schema}livestreams (id),
+ id {db.serial_primary_key},
download_url TEXT,
price_msat INTEGER NOT NULL DEFAULT 0,
name TEXT,
- producer INTEGER REFERENCES producers (id) NOT NULL
+ producer INTEGER REFERENCES {db.references_schema}producers (id) NOT NULL
);
"""
)
diff --git a/lnbits/extensions/livestream/models.py b/lnbits/extensions/livestream/models.py
index bfe82973..dd057c0c 100644
--- a/lnbits/extensions/livestream/models.py
+++ b/lnbits/extensions/livestream/models.py
@@ -1,27 +1,38 @@
import json
-from quart import url_for
-from typing import NamedTuple, Optional
-from lnurl import Lnurl, encode as lnurl_encode # type: ignore
-from lnurl.types import LnurlPayMetadata # type: ignore
+from typing import Optional
+
+from fastapi.params import Query
+from lnurl import Lnurl
+from lnurl import encode as lnurl_encode # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
+from lnurl.types import LnurlPayMetadata # type: ignore
+from pydantic.main import BaseModel
+from starlette.requests import Request
-class Livestream(NamedTuple):
+class CreateTrack(BaseModel):
+ name: str = Query(...)
+ download_url: str = Query(None)
+ price_msat: int = Query(None, ge=0)
+ producer_id: str = Query(None)
+ producer_name: str = Query(None)
+
+
+class Livestream(BaseModel):
id: int
wallet: str
fee_pct: int
current_track: Optional[int]
- @property
- def lnurl(self) -> Lnurl:
- url = url_for("livestream.lnurl_livestream", ls_id=self.id, _external=True)
+ def lnurl(self, request: Request) -> Lnurl:
+ url = request.url_for("livestream.lnurl_livestream", ls_id=self.id)
return lnurl_encode(url)
-class Track(NamedTuple):
+class Track(BaseModel):
id: int
- download_url: str
- price_msat: int
+ download_url: Optional[str]
+ price_msat: Optional[int]
name: str
producer: int
@@ -33,9 +44,8 @@ class Track(NamedTuple):
def max_sendable(self) -> int:
return max(50_000_000, self.price_msat * 5)
- @property
- def lnurl(self) -> Lnurl:
- url = url_for("livestream.lnurl_track", track_id=self.id, _external=True)
+ def lnurl(self, request: Request) -> Lnurl:
+ url = request.url_for("livestream.lnurl_track", track_id=self.id)
return lnurl_encode(url)
async def fullname(self) -> str:
@@ -59,22 +69,21 @@ class Track(NamedTuple):
return LnurlPayMetadata(json.dumps([["text/plain", description]]))
- def success_action(self, payment_hash: str) -> Optional[LnurlPaySuccessAction]:
+ def success_action(
+ self, payment_hash: str, request: Request
+ ) -> Optional[LnurlPaySuccessAction]:
if not self.download_url:
return None
+ url = request.url_for("livestream.track_redirect_download", track_id=self.id)
+ url_with_query = f"{url}?p={payment_hash}"
+
return UrlAction(
- url=url_for(
- "livestream.track_redirect_download",
- track_id=self.id,
- p=payment_hash,
- _external=True,
- ),
- description=f"Download the track {self.name}!",
+ url=url_with_query, description=f"Download the track {self.name}!"
)
-class Producer(NamedTuple):
+class Producer(BaseModel):
id: int
user: str
wallet: str
diff --git a/lnbits/extensions/livestream/tasks.py b/lnbits/extensions/livestream/tasks.py
index 52f86d15..c4cc3b57 100644
--- a/lnbits/extensions/livestream/tasks.py
+++ b/lnbits/extensions/livestream/tasks.py
@@ -1,23 +1,21 @@
+import asyncio
import json
-import trio
-from lnbits.core.models import Payment
-from lnbits.core.crud import create_payment
from lnbits.core import db as core_db
-from lnbits.tasks import register_invoice_listener, internal_invoice_paid
+from lnbits.core.crud import create_payment
+from lnbits.core.models import Payment
from lnbits.helpers import urlsafe_short_hash
+from lnbits.tasks import internal_invoice_listener, register_invoice_listener
-from .crud import get_track, get_producer, get_livestream_by_track
+from .crud import get_livestream_by_track, get_producer, get_track
-async def register_listeners():
- invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
- register_invoice_listener(invoice_paid_chan_send)
- await wait_for_paid_invoices(invoice_paid_chan_recv)
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
-
-async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
- async for payment in invoice_paid_chan:
+ while True:
+ payment = await invoice_queue.get()
await on_invoice_paid(payment)
@@ -80,7 +78,8 @@ async def on_invoice_paid(payment: Payment) -> None:
)
# manually send this for now
- await internal_invoice_paid.send(internal_checking_id)
+ # await internal_invoice_paid.send(internal_checking_id)
+ await internal_invoice_listener.put(internal_checking_id)
# so the flow is the following:
# - we receive, say, 1000 satoshis
diff --git a/lnbits/extensions/livestream/templates/livestream/_api_docs.html b/lnbits/extensions/livestream/templates/livestream/_api_docs.html
index fd92f0f3..4c497d7f 100644
--- a/lnbits/extensions/livestream/templates/livestream/_api_docs.html
+++ b/lnbits/extensions/livestream/templates/livestream/_api_docs.html
@@ -39,7 +39,7 @@
Curl example
curl -X GET {{ request.url_root }}api/v1/livestream -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ user.wallets[0].inkey }}"
@@ -61,7 +61,7 @@
curl -X PUT {{ request.url_root
}}api/v1/livestream/track/<track_id> -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ user.wallets[0].inkey }}"
@@ -83,7 +83,7 @@
curl -X PUT {{ request.url_root
}}api/v1/livestream/fee/<fee_pct> -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ user.wallets[0].inkey }}"
@@ -113,7 +113,7 @@
'{"name": <string>, "download_url": <string>,
"price_msat": <integer>, "producer_id": <integer>,
"producer_name": <string>}' -H "Content-type: application/json"
- -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
+ -H "X-Api-Key: {{ user.wallets[0].adminkey }}"
@@ -138,7 +138,7 @@
curl -X DELETE {{ request.url_root
}}api/v1/livestream/tracks/<track_id> -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ user.wallets[0].inkey }}"
diff --git a/lnbits/extensions/livestream/templates/livestream/index.html b/lnbits/extensions/livestream/templates/livestream/index.html
index e6585ac0..a93bab71 100644
--- a/lnbits/extensions/livestream/templates/livestream/index.html
+++ b/lnbits/extensions/livestream/templates/livestream/index.html
@@ -26,7 +26,7 @@
{% raw %}
-
+
{{ nextCurrentTrack && nextCurrentTrack ===
livestream.current_track ? 'Stop' : 'Set' }} current track
@@ -46,7 +46,7 @@
>
- Set percent rate
@@ -61,7 +61,7 @@
Tracks
- Add new track
@@ -215,7 +215,9 @@
- LNbits Livestream extension
+
+ {{SITE_TITLE}} Livestream extension
+
@@ -296,7 +298,7 @@
diff --git a/lnbits/extensions/livestream/views.py b/lnbits/extensions/livestream/views.py
index 8864ac2c..4f1bd1c5 100644
--- a/lnbits/extensions/livestream/views.py
+++ b/lnbits/extensions/livestream/views.py
@@ -1,38 +1,43 @@
-from quart import g, render_template, request, redirect
from http import HTTPStatus
+# from mmap import MAP_DENYWRITE
+
+from fastapi.param_functions import Depends
+from fastapi.params import Query
+from starlette.exceptions import HTTPException
+from starlette.requests import Request
+from starlette.responses import HTMLResponse, RedirectResponse
-from lnbits.decorators import check_user_exists, validate_uuids
-from lnbits.core.models import Payment
from lnbits.core.crud import get_wallet_payment
+from lnbits.core.models import Payment, User
+from lnbits.decorators import check_user_exists
-from . import livestream_ext
-from .crud import get_track, get_livestream_by_track
+from . import livestream_ext, livestream_renderer
+from .crud import get_livestream_by_track, get_track
-@livestream_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("livestream/index.html", user=g.user)
+@livestream_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return livestream_renderer().TemplateResponse(
+ "livestream/index.html", {"request": request, "user": user.dict()}
+ )
-@livestream_ext.route("/track/")
-async def track_redirect_download(track_id):
- payment_hash = request.args.get("p")
+@livestream_ext.get("/track/{track_id}", name="livestream.track_redirect_download")
+async def track_redirect_download(track_id, p: str = Query(...)):
+ payment_hash = p
track = await get_track(track_id)
ls = await get_livestream_by_track(track_id)
payment: Payment = await get_wallet_payment(ls.wallet, payment_hash)
if not payment:
- return (
- f"Couldn't find the payment {payment_hash} or track {track.id}.",
- HTTPStatus.NOT_FOUND,
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail=f"Couldn't find the payment {payment_hash} or track {track.id}.",
)
if payment.pending:
- return (
- f"Payment {payment_hash} wasn't received yet. Please try again in a minute.",
- HTTPStatus.PAYMENT_REQUIRED,
+ raise HTTPException(
+ status_code=HTTPStatus.PAYMENT_REQUIRED,
+ detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute.",
)
-
- return redirect(track.download_url)
+ return RedirectResponse(url=track.download_url)
diff --git a/lnbits/extensions/livestream/views_api.py b/lnbits/extensions/livestream/views_api.py
index c8816ac1..cc173a66 100644
--- a/lnbits/extensions/livestream/views_api.py
+++ b/lnbits/extensions/livestream/views_api.py
@@ -1,61 +1,56 @@
-from quart import g, jsonify
from http import HTTPStatus
-from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
+from fastapi.param_functions import Depends
+from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
+from starlette.exceptions import HTTPException
+from starlette.requests import Request # type: ignore
+
+from lnbits.decorators import WalletTypeInfo, get_key_type
+from lnbits.extensions.livestream.models import CreateTrack
from . import livestream_ext
from .crud import (
- get_or_create_livestream_by_wallet,
- add_track,
- get_tracks,
- update_track,
add_producer,
+ add_track,
+ delete_track_from_livestream,
+ get_or_create_livestream_by_wallet,
get_producers,
+ get_tracks,
update_current_track,
update_livestream_fee,
- delete_track_from_livestream,
+ update_track,
)
-@livestream_ext.route("/api/v1/livestream", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_livestream_from_wallet():
+@livestream_ext.get("/api/v1/livestream")
+async def api_livestream_from_wallet(
+ req: Request, g: WalletTypeInfo = Depends(get_key_type)
+):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
tracks = await get_tracks(ls.id)
producers = await get_producers(ls.id)
try:
- return (
- jsonify(
- {
- **ls._asdict(),
- **{
- "lnurl": ls.lnurl,
- "tracks": [
- dict(lnurl=track.lnurl, **track._asdict())
- for track in tracks
- ],
- "producers": [producer._asdict() for producer in producers],
- },
- }
- ),
- HTTPStatus.OK,
- )
+ return {
+ **ls.dict(),
+ **{
+ "lnurl": ls.lnurl(request=req),
+ "tracks": [
+ dict(lnurl=track.lnurl(request=req), **track.dict())
+ for track in tracks
+ ],
+ "producers": [producer.dict() for producer in producers],
+ },
+ }
except LnurlInvalidUrl:
- return (
- jsonify(
- {
- "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
- }
- ),
- HTTPStatus.UPGRADE_REQUIRED,
+ raise HTTPException(
+ status_code=HTTPStatus.UPGRADE_REQUIRED,
+ detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
)
-@livestream_ext.route("/api/v1/livestream/track/", methods=["PUT"])
-@api_check_wallet_key("invoice")
-async def api_update_track(track_id):
+@livestream_ext.put("/api/v1/livestream/track/{track_id}")
+async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
try:
id = int(track_id)
except ValueError:
@@ -65,71 +60,41 @@ async def api_update_track(track_id):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await update_current_track(ls.id, id)
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-@livestream_ext.route("/api/v1/livestream/fee/", methods=["PUT"])
-@api_check_wallet_key("invoice")
-async def api_update_fee(fee_pct):
+@livestream_ext.put("/api/v1/livestream/fee/{fee_pct}")
+async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await update_livestream_fee(ls.id, int(fee_pct))
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-@livestream_ext.route("/api/v1/livestream/tracks", methods=["POST"])
-@livestream_ext.route("/api/v1/livestream/tracks/", methods=["PUT"])
-@api_check_wallet_key("invoice")
-@api_validate_post_request(
- schema={
- "name": {"type": "string", "empty": False, "required": True},
- "download_url": {"type": "string", "empty": False, "required": False},
- "price_msat": {"type": "number", "min": 0, "required": False},
- "producer_id": {
- "type": "number",
- "required": True,
- "excludes": "producer_name",
- },
- "producer_name": {
- "type": "string",
- "required": True,
- "excludes": "producer_id",
- },
- }
-)
-async def api_add_track(id=None):
+@livestream_ext.post("/api/v1/livestream/tracks")
+@livestream_ext.put("/api/v1/livestream/tracks/{id}")
+async def api_add_track(
+ data: CreateTrack, id=None, g: WalletTypeInfo = Depends(get_key_type)
+):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
- if "producer_id" in g.data:
- p_id = g.data["producer_id"]
- elif "producer_name" in g.data:
- p_id = await add_producer(ls.id, g.data["producer_name"])
+ if data.producer_id:
+ p_id = data.producer_id
+ elif data.producer_name:
+ p_id = await add_producer(ls.id, data.producer_name)
else:
raise TypeError("need either producer_id or producer_name arguments")
if id:
await update_track(
- ls.id,
- id,
- g.data["name"],
- g.data.get("download_url"),
- g.data.get("price_msat", 0),
- p_id,
+ ls.id, id, data.name, data.download_url, data.price_msat or 0, p_id
)
- return "", HTTPStatus.OK
else:
- await add_track(
- ls.id,
- g.data["name"],
- g.data.get("download_url"),
- g.data.get("price_msat", 0),
- p_id,
- )
- return "", HTTPStatus.CREATED
+ await add_track(ls.id, data.name, data.download_url, data.price_msat or 0, p_id)
+ return
-@livestream_ext.route("/api/v1/livestream/tracks/", methods=["DELETE"])
-@api_check_wallet_key("invoice")
-async def api_delete_track(track_id):
+@livestream_ext.route("/api/v1/livestream/tracks/{track_id}")
+async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await delete_track_from_livestream(ls.id, track_id)
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
diff --git a/lnbits/extensions/lnaddress/README.md b/lnbits/extensions/lnaddress/README.md
new file mode 100644
index 00000000..d7e40503
--- /dev/null
+++ b/lnbits/extensions/lnaddress/README.md
@@ -0,0 +1,68 @@
+Lightning Address
+Rent Lightning Addresses on your domain
+LNAddress extension allows for someone to rent users lightning addresses on their domain.
+
+The extension is muted by default on the .env file and needs the admin of the LNbits instance to meet a few requirements on the server.
+
+## Requirements
+
+- Free Cloudflare account
+- Cloudflare as a DNS server provider
+- Cloudflare TOKEN and Cloudflare zone-ID where the domain is parked
+
+The server must provide SSL/TLS certificates to domain owners. If using caddy, this can be easily achieved with the Caddyfife snippet:
+
+```
+:443 {
+ reverse_proxy localhost:5000
+
+ tls @example.com {
+ on_demand
+ }
+}
+```
+
+fill in with your email.
+
+Certbot is also a possibity.
+
+## Usage
+
+1. Before adding a domain, you need to add the domain to Cloudflare and get an API key and Secret key\
+ \
+ You can use the _Edit zone DNS_ template Cloudflare provides.\
+ \
+ Edit the template as you like, if only using one domain you can narrow the scope of the template\
+ 
+
+2. Back on LNbits, click "ADD DOMAIN"\
+ 
+
+3. Fill the form with the domain information\
+ 
+
+ - select your wallet - add your domain
+ - cloudflare keys
+ - an optional webhook to get notified
+ - the amount, in sats, you'll rent the addresses, per day
+
+4. Your domains will show up on the _Domains_ section\
+ \
+ On the left side, is the link to share with users so they can rent an address on your domain. When someone creates an address, after pay, they will be shown on the _Addresses_ section\
+ 
+
+5. Addresses get automatically purged if expired or unpaid, after 24 hours. After expiration date, users will be granted a 24 hours period to renew their address!
+
+6. On the user/buyer side, the webpage will present the _Create_ or _Renew_ address tabs. On the Create tab:\
+ 
+ - optional email
+ - the alias or username they want on your domain
+ - the LNbits URL, if not the same instance (for example the user has an LNbits wallet on https://s.lnbits.com and is renting an address from https://lnbits.com)
+ - the _Admin key_ for the wallet
+ - how many days to rent a username for - bellow shows the per day cost and total cost the user will have to pay
+7. On the Renew tab:\
+ 
+ - enter the Alias/username
+ - enter the wallet key
+ - press the _GET INFO_ button to retrieve your address data
+ - an expiration date will appear and the option to extend the duration of your address
diff --git a/lnbits/extensions/lnaddress/__init__.py b/lnbits/extensions/lnaddress/__init__.py
new file mode 100644
index 00000000..6e8afa37
--- /dev/null
+++ b/lnbits/extensions/lnaddress/__init__.py
@@ -0,0 +1,26 @@
+import asyncio
+
+from fastapi import APIRouter
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
+
+db = Database("ext_lnaddress")
+
+lnaddress_ext: APIRouter = APIRouter(prefix="/lnaddress", tags=["lnaddress"])
+
+
+def lnaddress_renderer():
+ return template_renderer(["lnbits/extensions/lnaddress/templates"])
+
+
+from .lnurl import * # noqa
+from .tasks import wait_for_paid_invoices
+from .views import * # noqa
+from .views_api import * # noqa
+
+
+def lnaddress_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/lnaddress/cloudflare.py b/lnbits/extensions/lnaddress/cloudflare.py
new file mode 100644
index 00000000..981a37b0
--- /dev/null
+++ b/lnbits/extensions/lnaddress/cloudflare.py
@@ -0,0 +1,55 @@
+import json
+
+import httpx
+
+from lnbits.extensions.lnaddress.models import Domains
+
+
+async def cloudflare_create_record(domain: Domains, ip: str):
+ url = (
+ "https://api.cloudflare.com/client/v4/zones/"
+ + domain.cf_zone_id
+ + "/dns_records"
+ )
+ header = {
+ "Authorization": "Bearer " + domain.cf_token,
+ "Content-Type": "application/json",
+ }
+
+ cf_response = ""
+ async with httpx.AsyncClient() as client:
+ try:
+ r = await client.post(
+ url,
+ headers=header,
+ json={
+ "type": "CNAME",
+ "name": domain.domain,
+ "content": ip,
+ "ttl": 0,
+ "proxied": False,
+ },
+ timeout=40,
+ )
+ cf_response = json.loads(r.text)
+ except AssertionError:
+ cf_response = "Error occured"
+ return cf_response
+
+
+async def cloudflare_deleterecord(domain: Domains, domain_id: str):
+ url = (
+ "https://api.cloudflare.com/client/v4/zones/"
+ + domain.cf_zone_id
+ + "/dns_records"
+ )
+ header = {
+ "Authorization": "Bearer " + domain.cf_token,
+ "Content-Type": "application/json",
+ }
+ async with httpx.AsyncClient() as client:
+ try:
+ r = await client.delete(url + "/" + domain_id, headers=header, timeout=40)
+ cf_response = r.text
+ except AssertionError:
+ cf_response = "Error occured"
diff --git a/lnbits/extensions/lnaddress/config.json b/lnbits/extensions/lnaddress/config.json
new file mode 100644
index 00000000..f9946f35
--- /dev/null
+++ b/lnbits/extensions/lnaddress/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Lightning Address",
+ "short_description": "Sell LN addresses for your domain",
+ "icon": "alternate_email",
+ "contributors": ["talvasconcelos"]
+}
diff --git a/lnbits/extensions/lnaddress/crud.py b/lnbits/extensions/lnaddress/crud.py
new file mode 100644
index 00000000..3b5822b4
--- /dev/null
+++ b/lnbits/extensions/lnaddress/crud.py
@@ -0,0 +1,194 @@
+from datetime import datetime, timedelta
+from typing import List, Optional, Union
+
+from lnbits.helpers import urlsafe_short_hash
+
+from . import db
+from .models import Addresses, CreateAddress, CreateDomain, Domains
+
+
+async def create_domain(data: CreateDomain) -> Domains:
+ domain_id = urlsafe_short_hash()
+ await db.execute(
+ """
+ INSERT INTO lnaddress.domain (id, wallet, domain, webhook, cf_token, cf_zone_id, cost)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ domain_id,
+ data.wallet,
+ data.domain,
+ data.webhook,
+ data.cf_token,
+ data.cf_zone_id,
+ data.cost,
+ ),
+ )
+
+ new_domain = await get_domain(domain_id)
+ assert new_domain, "Newly created domain couldn't be retrieved"
+ return new_domain
+
+
+async def update_domain(domain_id: str, **kwargs) -> Domains:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE lnaddress.domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id)
+ )
+ row = await db.fetchone("SELECT * FROM lnaddress.domain WHERE id = ?", (domain_id,))
+ assert row, "Newly updated domain couldn't be retrieved"
+ return Domains(**row)
+
+
+async def delete_domain(domain_id: str) -> None:
+
+ await db.execute("DELETE FROM lnaddress.domain WHERE id = ?", (domain_id,))
+
+
+async def get_domain(domain_id: str) -> Optional[Domains]:
+ row = await db.fetchone("SELECT * FROM lnaddress.domain WHERE id = ?", (domain_id,))
+ return Domains(**row) if row else None
+
+
+async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domains]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM lnaddress.domain WHERE wallet IN ({q})", (*wallet_ids,)
+ )
+
+ return [Domains(**row) for row in rows]
+
+
+## ADRESSES
+
+
+async def create_address(
+ payment_hash: str, wallet: str, data: CreateAddress
+) -> Addresses:
+ await db.execute(
+ """
+ INSERT INTO lnaddress.address (id, wallet, domain, email, username, wallet_key, wallet_endpoint, sats, duration, paid)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ payment_hash,
+ wallet,
+ data.domain,
+ data.email,
+ data.username,
+ data.wallet_key,
+ data.wallet_endpoint,
+ data.sats,
+ data.duration,
+ False,
+ ),
+ )
+
+ new_address = await get_address(payment_hash)
+ assert new_address, "Newly created address couldn't be retrieved"
+ return new_address
+
+
+async def get_address(address_id: str) -> Optional[Addresses]:
+ row = await db.fetchone(
+ "SELECT a.* FROM lnaddress.address AS a INNER JOIN lnaddress.domain AS d ON a.id = ? AND a.domain = d.id",
+ (address_id,),
+ )
+ return Addresses(**row) if row else None
+
+
+async def get_address_by_username(username: str, domain: str) -> Optional[Addresses]:
+ row = await db.fetchone(
+ "SELECT a.* FROM lnaddress.address AS a INNER JOIN lnaddress.domain AS d ON a.username = ? AND d.domain = ?",
+ (username, domain),
+ )
+
+ return Addresses(**row) if row else None
+
+
+async def delete_address(address_id: str) -> None:
+ await db.execute("DELETE FROM lnaddress.address WHERE id = ?", (address_id,))
+
+
+async def get_addresses(wallet_ids: Union[str, List[str]]) -> List[Addresses]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM lnaddress.address WHERE wallet IN ({q})", (*wallet_ids,)
+ )
+ return [Addresses(**row) for row in rows]
+
+
+async def set_address_paid(payment_hash: str) -> Addresses:
+ address = await get_address(payment_hash)
+
+ if address.paid == False:
+ await db.execute(
+ """
+ UPDATE lnaddress.address
+ SET paid = true
+ WHERE id = ?
+ """,
+ (payment_hash,),
+ )
+
+ new_address = await get_address(payment_hash)
+ assert new_address, "Newly paid address couldn't be retrieved"
+ return new_address
+
+
+async def set_address_renewed(address_id: str, duration: int):
+ address = await get_address(address_id)
+
+ extend_duration = int(address.duration) + duration
+ await db.execute(
+ """
+ UPDATE lnaddress.address
+ SET duration = ?
+ WHERE id = ?
+ """,
+ (extend_duration, address_id),
+ )
+ updated_address = await get_address(address_id)
+ assert updated_address, "Renewed address couldn't be retrieved"
+ return updated_address
+
+
+async def check_address_available(username: str, domain: str):
+ (row,) = await db.fetchone(
+ "SELECT COUNT(username) FROM lnaddress.address WHERE username = ? AND domain = ?",
+ (username, domain),
+ )
+ return row
+
+
+async def purge_addresses(domain_id: str):
+
+ rows = await db.fetchall(
+ "SELECT * FROM lnaddress.address WHERE domain = ?", (domain_id,)
+ )
+
+ now = datetime.now().timestamp()
+
+ for row in rows:
+ r = Addresses(**row).dict()
+
+ start = datetime.fromtimestamp(r["time"])
+ paid = r["paid"]
+ pay_expire = now > start.timestamp() + 86400 # if payment wasn't made in 1 day
+ expired = (
+ now > (start + timedelta(days=r["duration"] + 1)).timestamp()
+ ) # give user 1 day to topup is address
+
+ if not paid and pay_expire:
+ print("DELETE UNP_PAY_EXP", r["username"])
+ await delete_address(r["id"])
+
+ if paid and expired:
+ print("DELETE PAID_EXP", r["username"])
+ await delete_address(r["id"])
diff --git a/lnbits/extensions/lnaddress/lnurl.py b/lnbits/extensions/lnaddress/lnurl.py
new file mode 100644
index 00000000..fa26fa91
--- /dev/null
+++ b/lnbits/extensions/lnaddress/lnurl.py
@@ -0,0 +1,90 @@
+import hashlib
+import json
+from datetime import datetime, timedelta
+
+import httpx
+from fastapi.params import Query
+from lnurl import ( # type: ignore
+ LnurlErrorResponse,
+ LnurlPayActionResponse,
+ LnurlPayResponse,
+)
+from starlette.requests import Request
+from starlette.responses import HTMLResponse
+
+from . import lnaddress_ext
+from .crud import get_address, get_address_by_username, get_domain
+
+
+async def lnurl_response(username: str, domain: str, request: Request):
+ address = await get_address_by_username(username, domain)
+
+ if not address:
+ return {"status": "ERROR", "reason": "Address not found."}
+
+ ## CHECK IF USER IS STILL VALID/PAYING
+ now = datetime.now().timestamp()
+ start = datetime.fromtimestamp(address.time)
+ expiration = (start + timedelta(days=address.duration)).timestamp()
+
+ if now > expiration:
+ return LnurlErrorResponse(reason="Address has expired.").dict()
+
+ resp = {
+ "tag": "payRequest",
+ "callback": request.url_for("lnaddress.lnurl_callback", address_id=address.id),
+ "metadata": await address.lnurlpay_metadata(domain=domain),
+ "minSendable": 1000,
+ "maxSendable": 1000000000,
+ }
+
+ print("RESP", resp)
+ return resp
+
+
+@lnaddress_ext.get("/lnurl/cb/{address_id}", name="lnaddress.lnurl_callback")
+async def lnurl_callback(address_id, amount: int = Query(...)):
+ print("PING")
+ address = await get_address(address_id)
+ if not address:
+ return LnurlErrorResponse(reason=f"Address not found").dict()
+
+ amount_received = amount
+
+ domain = await get_domain(address.domain)
+
+ base_url = (
+ address.wallet_endpoint[:-1]
+ if address.wallet_endpoint.endswith("/")
+ else address.wallet_endpoint
+ )
+
+ async with httpx.AsyncClient() as client:
+ try:
+ call = await client.post(
+ base_url + "/api/v1/payments",
+ headers={
+ "X-Api-Key": address.wallet_key,
+ "Content-Type": "application/json",
+ },
+ json={
+ "out": False,
+ "amount": int(amount_received / 1000),
+ "description_hash": hashlib.sha256(
+ (await address.lnurlpay_metadata(domain=domain.domain)).encode(
+ "utf-8"
+ )
+ ).hexdigest(),
+ "extra": {"tag": f"Payment to {address.username}@{domain.domain}"},
+ },
+ timeout=40,
+ )
+
+ r = call.json()
+ except AssertionError as e:
+ return LnurlErrorResponse(reason="ERROR")
+
+ # resp = LnurlPayActionResponse(pr=r["payment_request"], routes=[])
+ resp = {"pr": r["payment_request"], "routes": []}
+
+ return resp
diff --git a/lnbits/extensions/lnaddress/migrations.py b/lnbits/extensions/lnaddress/migrations.py
new file mode 100644
index 00000000..1724e186
--- /dev/null
+++ b/lnbits/extensions/lnaddress/migrations.py
@@ -0,0 +1,39 @@
+async def m001_initial(db):
+ await db.execute(
+ """
+ CREATE TABLE lnaddress.domain (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ domain TEXT NOT NULL,
+ webhook TEXT,
+ cf_token TEXT NOT NULL,
+ cf_zone_id TEXT NOT NULL,
+ cost INTEGER NOT NULL,
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
+ );
+ """
+ )
+
+
+async def m002_addresses(db):
+ await db.execute(
+ """
+ CREATE TABLE lnaddress.address (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ domain TEXT NOT NULL,
+ email TEXT,
+ username TEXT NOT NULL,
+ wallet_key TEXT NOT NULL,
+ wallet_endpoint TEXT NOT NULL,
+ sats INTEGER NOT NULL,
+ duration INTEGER NOT NULL,
+ paid BOOLEAN NOT NULL,
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
+ );
+ """
+ )
diff --git a/lnbits/extensions/lnaddress/models.py b/lnbits/extensions/lnaddress/models.py
new file mode 100644
index 00000000..248f856c
--- /dev/null
+++ b/lnbits/extensions/lnaddress/models.py
@@ -0,0 +1,57 @@
+import json
+from typing import Optional
+
+from fastapi.params import Query
+from lnurl.types import LnurlPayMetadata
+from pydantic.main import BaseModel
+
+
+class CreateDomain(BaseModel):
+ wallet: str = Query(...)
+ domain: str = Query(...)
+ cf_token: str = Query(...)
+ cf_zone_id: str = Query(...)
+ webhook: str = Query(None)
+ cost: int = Query(..., ge=0)
+
+
+class Domains(BaseModel):
+ id: str
+ wallet: str
+ domain: str
+ cf_token: str
+ cf_zone_id: str
+ webhook: Optional[str]
+ cost: int
+ time: int
+
+
+class CreateAddress(BaseModel):
+ domain: str = Query(...)
+ username: str = Query(...)
+ email: str = Query(None)
+ wallet_endpoint: str = Query(...)
+ wallet_key: str = Query(...)
+ sats: int = Query(..., ge=0)
+ duration: int = Query(..., ge=1)
+
+
+class Addresses(BaseModel):
+ id: str
+ wallet: str
+ domain: str
+ email: Optional[str]
+ username: str
+ wallet_key: str
+ wallet_endpoint: str
+ sats: int
+ duration: int
+ paid: bool
+ time: int
+
+ async def lnurlpay_metadata(self, domain) -> LnurlPayMetadata:
+ text = f"Payment to {self.username}"
+ identifier = f"{self.username}@{domain}"
+ metadata = [["text/plain", text], ["text/identifier", identifier]]
+
+ return LnurlPayMetadata(json.dumps(metadata))
diff --git a/lnbits/extensions/lnaddress/tasks.py b/lnbits/extensions/lnaddress/tasks.py
new file mode 100644
index 00000000..9702c70b
--- /dev/null
+++ b/lnbits/extensions/lnaddress/tasks.py
@@ -0,0 +1,61 @@
+import asyncio
+
+import httpx
+
+from lnbits.core.models import Payment
+from lnbits.tasks import register_invoice_listener
+
+from .crud import get_address, get_domain, set_address_paid, set_address_renewed
+
+
+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 call_webhook_on_paid(payment_hash):
+ ### Use webhook to notify about cloudflare registration
+ address = await get_address(payment_hash)
+ domain = await get_domain(address.domain)
+
+ if not domain.webhook:
+ return
+
+ async with httpx.AsyncClient() as client:
+ try:
+ r = await client.post(
+ domain.webhook,
+ json={
+ "domain": domain.domain,
+ "address": address.username,
+ "email": address.email,
+ "cost": str(address.sats) + " sats",
+ "duration": str(address.duration) + " days",
+ },
+ timeout=40,
+ )
+ except AssertionError:
+ webhook = None
+
+
+async def on_invoice_paid(payment: Payment) -> None:
+ if "lnaddress" == payment.extra.get("tag"):
+
+ await payment.set_pending(False)
+ await set_address_paid(payment_hash=payment.payment_hash)
+ await call_webhook_on_paid(payment_hash=payment.payment_hash)
+
+ elif "renew lnaddress" == payment.extra.get("tag"):
+
+ await payment.set_pending(False)
+ await set_address_renewed(
+ address_id=payment.extra["id"], duration=payment.extra["duration"]
+ )
+ await call_webhook_on_paid(payment_hash=payment.payment_hash)
+
+ else:
+ return
diff --git a/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html b/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html
new file mode 100644
index 00000000..ab7ab4bd
--- /dev/null
+++ b/lnbits/extensions/lnaddress/templates/lnaddress/_api_docs.html
@@ -0,0 +1,174 @@
+
+
+
+
+ lnAddress: Get paid sats to sell lightning addresses on your domains
+
+
+ Charge people for using your domain name...
+
+ More details
+
+
+ Created by,
+ talvasconcelos
+
+
+
+
+
+
+
+
+ GET
+ lnaddress/api/v1/domains
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ JSON list of users
+ Curl example
+ curl -X GET {{ request.url_root }}lnaddress/api/v1/domains -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ POST
+ /lnAddress/api/v1/domains
+ Headers
+ {"X-Api-Key": <string>, "Content-type":
+ "application/json"}
+
+ Body (application/json) - "wallet" is a YOUR wallet ID
+
+ {"wallet": <string>, "domain": <string>, "cf_token":
+ <string>,"cf_zone_id": <string>,"webhook": <Optional
+ string> ,"cost": <integer>}
+
+ Returns 201 CREATED (application/json)
+
+ {"id": <string>, "wallet": <string>, "domain":
+ <string>, "webhook": <string>, "cf_token": <string>,
+ "cf_zone_id": <string>, "cost": <integer>}
+ Curl example
+ curl -X POST {{ request.url_root }}lnaddress/api/v1/domains -d
+ '{"wallet": "{{ user.wallets[0].id }}", "domain": <string>,
+ "cf_token": <string>,"cf_zone_id": <string>,"webhook":
+ <Optional string> ,"cost": <integer>}' -H "X-Api-Key: {{
+ user.wallets[0].inkey }}" -H "Content-type: application/json"
+
+
+
+
+
+
+
+ DELETE
+ /lnaddress/api/v1/domains/<domain_id>
+ Headers
+ {"X-Api-Key": <string>}
+ Curl example
+ curl -X DELETE {{ request.url_root
+ }}lnaddress/api/v1/domains/<domain_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ GET
+ lnaddress/api/v1/addresses
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ JSON list of addresses
+ Curl example
+ curl -X GET {{ request.url_root }}lnaddress/api/v1/addresses -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ GET
+ lnaddress/api/v1/address/<domain>/<username>/<wallet_key>
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ JSON list of addresses
+ Curl example
+ curl -X GET {{ request.url_root
+ }}lnaddress/api/v1/address/<domain>/<username>/<wallet_key>
+ -H "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ POST
+ /lnaddress/api/v1/address/<domain_id>
+ Headers
+ {"X-Api-Key": <string>}
+ Curl example
+ curl -X POST {{ request.url_root
+ }}lnaddress/api/v1/address/<domain_id> -d '{"domain":
+ <string>, "username": <string>,"email": <Optional
+ string>, "wallet_endpoint": <string>, "wallet_key":
+ <string>, "sats": <integer> "duration": <integer>,}'
+ -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H "Content-type:
+ application/json"
+
+
+
+
+
diff --git a/lnbits/extensions/lnaddress/templates/lnaddress/display.html b/lnbits/extensions/lnaddress/templates/lnaddress/display.html
new file mode 100644
index 00000000..7164752c
--- /dev/null
+++ b/lnbits/extensions/lnaddress/templates/lnaddress/display.html
@@ -0,0 +1,435 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+
+
+ tab = val.name"
+ >
+ tab = val.name"
+ >
+
+
+
+
+
+
+
+ {{ domain_domain }}
+
+
+ Your Lightning Address: {% raw
+ %}{{this.formDialog.data.username}}{% endraw %}@{{domain_domain}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cost per day: {{ domain_cost }} sats
+ {% raw %} Total cost: {{amountSats}} sats {% endraw %}
+
+
+ Submit
+ Cancel
+
+
+
+
+
+
+ {{ domain_domain }}
+
+
+ Renew your Lightning Address: {% raw
+ %}{{this.formDialog.data.username}}{% endraw %}@{{domain_domain}}
+
+
+
+
+
+
+
+
+
+ {% raw %}
+
+ LN Address:
+ {{renewDialog.data.username}}@{{renewDialog.data.domain}}
+
+ Expires at: {{renewDialog.data.expiration}}
+
+ {% endraw %}
+
+ Get Info
+
+
+
+
+ Cost per day: {{ domain_cost }} sats
+ {% raw %} Total cost: {{amountSats}} sats {% endraw %}
+
+
+ Submit
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Copy invoice
+ Close
+
+
+
+
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
diff --git a/lnbits/extensions/lnaddress/templates/lnaddress/index.html b/lnbits/extensions/lnaddress/templates/lnaddress/index.html
new file mode 100644
index 00000000..dffef837
--- /dev/null
+++ b/lnbits/extensions/lnaddress/templates/lnaddress/index.html
@@ -0,0 +1,499 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+ Add Domain
+
+
+
+
+
+
+ Domains
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+ Addresses
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} LN Address extension
+
+
+
+
+ {% include "lnaddress/_api_docs.html" %}
+
+
+
+
+
+
+
+
+ The domain to use ex: "example.com"
+
+
+ Check extension documentation!
+
+ Your API key in cloudflare
+
+
+ Create a "Edit zone DNS" API token in cloudflare
+
+
+ How much to charge per day
+
+ Update Form
+ Create Domain
+ Cancel
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/lnaddress/views.py b/lnbits/extensions/lnaddress/views.py
new file mode 100644
index 00000000..8c838f0c
--- /dev/null
+++ b/lnbits/extensions/lnaddress/views.py
@@ -0,0 +1,50 @@
+from http import HTTPStatus
+from urllib.parse import urlparse
+
+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.crud import get_wallet
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import lnaddress_ext, lnaddress_renderer
+from .crud import get_domain, purge_addresses
+
+templates = Jinja2Templates(directory="templates")
+
+
+@lnaddress_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return lnaddress_renderer().TemplateResponse(
+ "lnaddress/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@lnaddress_ext.get("/{domain_id}", response_class=HTMLResponse)
+async def display(domain_id, request: Request):
+ domain = await get_domain(domain_id)
+ if not domain:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
+ )
+
+ await purge_addresses(domain_id)
+
+ wallet = await get_wallet(domain.wallet)
+ url = urlparse(str(request.url))
+
+ return lnaddress_renderer().TemplateResponse(
+ "lnaddress/display.html",
+ {
+ "request": request,
+ "domain_id": domain.id,
+ "domain_domain": domain.domain,
+ "domain_cost": domain.cost,
+ "domain_wallet_inkey": wallet.inkey,
+ "root_url": f"{url.scheme}://{url.netloc}",
+ },
+ )
diff --git a/lnbits/extensions/lnaddress/views_api.py b/lnbits/extensions/lnaddress/views_api.py
new file mode 100644
index 00000000..b1f1f004
--- /dev/null
+++ b/lnbits/extensions/lnaddress/views_api.py
@@ -0,0 +1,256 @@
+from http import HTTPStatus
+from urllib.parse import urlparse
+
+from fastapi import Request
+from fastapi.params import Depends, Query
+from starlette.exceptions import HTTPException
+
+from lnbits.core.crud import get_user
+from lnbits.core.services import check_invoice_status, create_invoice
+from lnbits.decorators import WalletTypeInfo, get_key_type
+from lnbits.extensions.lnaddress.models import CreateAddress, CreateDomain
+
+from . import lnaddress_ext
+from .cloudflare import cloudflare_create_record, cloudflare_deleterecord
+from .crud import (
+ check_address_available,
+ create_address,
+ create_domain,
+ delete_address,
+ delete_domain,
+ get_address,
+ get_address_by_username,
+ get_addresses,
+ get_domain,
+ get_domains,
+ update_domain,
+)
+
+
+# DOMAINS
+@lnaddress_ext.get("/api/v1/domains")
+async def api_domains(
+ g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
+):
+ wallet_ids = [g.wallet.id]
+
+ if all_wallets:
+ wallet_ids = (await get_user(g.wallet.user)).wallet_ids
+
+ return [domain.dict() for domain in await get_domains(wallet_ids)]
+
+
+@lnaddress_ext.post("/api/v1/domains")
+@lnaddress_ext.put("/api/v1/domains/{domain_id}")
+async def api_domain_create(
+ request: Request,
+ data: CreateDomain,
+ domain_id=None,
+ g: WalletTypeInfo = Depends(get_key_type),
+):
+ if domain_id:
+ domain = await get_domain(domain_id)
+
+ if not domain:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
+ )
+
+ if domain.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your domain"
+ )
+
+ domain = await update_domain(domain_id, **data.dict())
+ else:
+
+ domain = await create_domain(data=data)
+ root_url = urlparse(str(request.url)).netloc
+
+ cf_response = await cloudflare_create_record(domain=domain, ip=root_url)
+
+ if not cf_response or cf_response["success"] != True:
+ await delete_domain(domain.id)
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Problem with cloudflare: "
+ + cf_response["errors"][0]["message"],
+ )
+
+ return domain.dict()
+
+
+@lnaddress_ext.delete("/api/v1/domains/{domain_id}")
+async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)):
+ domain = await get_domain(domain_id)
+
+ if not domain:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
+ )
+
+ if domain.wallet != g.wallet.id:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain")
+
+ await delete_domain(domain_id)
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
+
+
+# ADDRESSES
+
+
+@lnaddress_ext.get("/api/v1/addresses")
+async def api_addresses(
+ g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
+):
+ wallet_ids = [g.wallet.id]
+
+ if all_wallets:
+ wallet_ids = (await get_user(g.wallet.user)).wallet_ids
+
+ return [address.dict() for address in await get_addresses(wallet_ids)]
+
+
+@lnaddress_ext.get("/api/v1/address/availabity/{domain_id}/{username}")
+async def api_check_available_username(domain_id, username):
+ used_username = await check_address_available(username, domain_id)
+
+ return used_username
+
+
+@lnaddress_ext.get("/api/v1/address/{domain}/{username}/{wallet_key}")
+async def api_get_user_info(username, wallet_key, domain):
+ address = await get_address_by_username(username, domain)
+
+ if not address:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Address does not exist."
+ )
+
+ if address.wallet_key != wallet_key:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Incorrect user/wallet information.",
+ )
+
+ return address.dict()
+
+
+@lnaddress_ext.post("/api/v1/address/{domain_id}")
+@lnaddress_ext.put("/api/v1/address/{domain_id}/{user}/{wallet_key}")
+async def api_lnaddress_make_address(
+ domain_id, data: CreateAddress, user=None, wallet_key=None
+):
+ domain = await get_domain(domain_id)
+
+ # If the request is coming for the non-existant domain
+ if not domain:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="The domain does not exist."
+ )
+
+ domain_cost = domain.cost
+ sats = data.sats
+
+ ## FAILSAFE FOR CREATING ADDRESSES BY API
+ if domain_cost * data.duration != data.sats:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="The amount is not correct. Either 'duration', or 'sats' are wrong.",
+ )
+
+ if user:
+ address = await get_address_by_username(user, domain.domain)
+
+ if not address:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="The address does not exist."
+ )
+
+ if address.wallet_key != wallet_key:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your address."
+ )
+
+ try:
+ payment_hash, payment_request = await create_invoice(
+ wallet_id=domain.wallet,
+ amount=data.sats,
+ memo=f"Renew {data.username}@{domain.domain} for {sats} sats for {data.duration} more days",
+ extra={
+ "tag": "renew lnaddress",
+ "id": address.id,
+ "duration": data.duration,
+ },
+ )
+
+ except Exception as e:
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)
+ )
+ else:
+ used_username = await check_address_available(data.username, data.domain)
+ # If username is already taken
+ if used_username:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Alias/username already taken.",
+ )
+
+ ## ALL OK - create an invoice and return it to the user
+
+ try:
+ payment_hash, payment_request = await create_invoice(
+ wallet_id=domain.wallet,
+ amount=sats,
+ memo=f"LNAddress {data.username}@{domain.domain} for {sats} sats for {data.duration} days",
+ extra={"tag": "lnaddress"},
+ )
+ except Exception as e:
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)
+ )
+
+ address = await create_address(
+ payment_hash=payment_hash, wallet=domain.wallet, data=data
+ )
+
+ if not address:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="LNAddress could not be fetched.",
+ )
+
+ return {"payment_hash": payment_hash, "payment_request": payment_request}
+
+
+@lnaddress_ext.get("/api/v1/addresses/{payment_hash}")
+async def api_address_send_address(payment_hash):
+ address = await get_address(payment_hash)
+ domain = await get_domain(address.domain)
+ try:
+ status = await check_invoice_status(domain.wallet, payment_hash)
+ is_paid = not status.pending
+ except Exception as e:
+ return {"paid": False, "error": str(e)}
+
+ if is_paid:
+ return {"paid": True}
+
+ return {"paid": False}
+
+
+@lnaddress_ext.delete("/api/v1/addresses/{address_id}")
+async def api_address_delete(address_id, g: WalletTypeInfo = Depends(get_key_type)):
+ address = await get_address(address_id)
+ if not address:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Address does not exist."
+ )
+ if address.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your address."
+ )
+
+ await delete_address(address_id)
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
diff --git a/lnbits/extensions/lndhub/__init__.py b/lnbits/extensions/lndhub/__init__.py
index 7610b0a3..5980ab0d 100644
--- a/lnbits/extensions/lndhub/__init__.py
+++ b/lnbits/extensions/lndhub/__init__.py
@@ -1,12 +1,18 @@
-from quart import Blueprint
+from fastapi import APIRouter
+
from lnbits.db import Database
+from lnbits.helpers import template_renderer
db = Database("ext_lndhub")
-lndhub_ext: Blueprint = Blueprint(
- "lndhub", __name__, static_folder="static", template_folder="templates"
-)
+lndhub_ext: APIRouter = APIRouter(prefix="/lndhub", tags=["lndhub"])
-from .views_api import * # noqa
+def lndhub_renderer():
+ return template_renderer(["lnbits/extensions/lndhub/templates"])
+
+
+from .decorators import * # noqa
+from .utils import * # noqa
from .views import * # noqa
+from .views_api import * # noqa
diff --git a/lnbits/extensions/lndhub/config.json b/lnbits/extensions/lndhub/config.json
index 2b536d7d..6285ff80 100644
--- a/lnbits/extensions/lndhub/config.json
+++ b/lnbits/extensions/lndhub/config.json
@@ -1,6 +1,6 @@
{
"name": "LndHub",
- "short_description": "Access lnbits from BlueWallet or Zeus.",
+ "short_description": "Access lnbits from BlueWallet or Zeus",
"icon": "navigation",
"contributors": ["fiatjaf"]
}
diff --git a/lnbits/extensions/lndhub/decorators.py b/lnbits/extensions/lndhub/decorators.py
index c9c3bb71..14931164 100644
--- a/lnbits/extensions/lndhub/decorators.py
+++ b/lnbits/extensions/lndhub/decorators.py
@@ -1,29 +1,44 @@
from base64 import b64decode
-from quart import jsonify, g, request
-from functools import wraps
+from fastapi.param_functions import Security
-from lnbits.core.crud import get_wallet_for_key
+from fastapi.security.api_key import APIKeyHeader
+
+from fastapi import Request, status
+from starlette.exceptions import HTTPException
+
+from lnbits.decorators import WalletTypeInfo, get_key_type # type: ignore
-def check_wallet(requires_admin=False):
- def wrap(view):
- @wraps(view)
- async def wrapped_view(**kwargs):
- token = request.headers["Authorization"].split("Bearer ")[1]
- key_type, key = b64decode(token).decode("utf-8").split(":")
+api_key_header_auth = APIKeyHeader(
+ name="AUTHORIZATION",
+ auto_error=False,
+ description="Admin or Invoice key for LNDHub API's",
+)
- if requires_admin and key_type != "admin":
- return jsonify(
- {"error": True, "code": 2, "message": "insufficient permissions"}
- )
- g.wallet = await get_wallet_for_key(key, key_type)
- if not g.wallet:
- return jsonify(
- {"error": True, "code": 2, "message": "insufficient permissions"}
- )
- return await view(**kwargs)
+async def check_wallet(
+ r: Request, api_key_header_auth: str = Security(api_key_header_auth)
+) -> WalletTypeInfo:
+ if not api_key_header_auth:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid auth key"
+ )
- return wrapped_view
+ t = api_key_header_auth.split(" ")[1]
+ _, token = b64decode(t).decode("utf-8").split(":")
- return wrap
+ return await get_key_type(r, api_key_header=token)
+
+
+async def require_admin_key(
+ r: Request, api_key_header_auth: str = Security(api_key_header_auth)
+):
+ wallet = await check_wallet(r, api_key_header_auth)
+ if wallet.wallet_type != 0:
+ # If wallet type is not admin then return the unauthorized status
+ # This also covers when the user passes an invalid key type
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin key required."
+ )
+ else:
+ return wallet
diff --git a/lnbits/extensions/lndhub/templates/lndhub/index.html b/lnbits/extensions/lndhub/templates/lndhub/index.html
index de6e2305..2c282e59 100644
--- a/lnbits/extensions/lndhub/templates/lndhub/index.html
+++ b/lnbits/extensions/lndhub/templates/lndhub/index.html
@@ -52,7 +52,9 @@
- LNbits LndHub extension
+
+ {{SITE_TITLE}} LndHub extension
+
@@ -74,7 +76,7 @@
el: '#vue',
mixins: [windowMixin],
data: function () {
- var wallets = JSON.parse('{{ g.user.wallets | tojson }}')
+ var wallets = JSON.parse('{{ user.wallets | tojson }}')
.map(LNbits.map.wallet)
.map(wallet => ({
label: wallet.name,
diff --git a/lnbits/extensions/lndhub/views.py b/lnbits/extensions/lndhub/views.py
index 2bc01fc1..4b015c09 100644
--- a/lnbits/extensions/lndhub/views.py
+++ b/lnbits/extensions/lndhub/views.py
@@ -1,11 +1,12 @@
-from quart import render_template, g
-
-from lnbits.decorators import check_user_exists, validate_uuids
-from . import lndhub_ext
+from lnbits.decorators import check_user_exists
+from . import lndhub_ext, lndhub_renderer
+from fastapi import Request
+from fastapi.params import Depends
+from lnbits.core.models import User
-@lndhub_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def lndhub_index():
- return await render_template("lndhub/index.html", user=g.user)
+@lndhub_ext.get("/")
+async def lndhub_index(request: Request, user: User = Depends(check_user_exists)):
+ return lndhub_renderer().TemplateResponse(
+ "lndhub/index.html", {"request": request, "user": user.dict()}
+ )
diff --git a/lnbits/extensions/lndhub/views_api.py b/lnbits/extensions/lndhub/views_api.py
index 5f3b2a8b..8cbf5b01 100644
--- a/lnbits/extensions/lndhub/views_api.py
+++ b/lnbits/extensions/lndhub/views_api.py
@@ -1,230 +1,237 @@
import time
-from base64 import urlsafe_b64encode
-from quart import jsonify, g, request
+import asyncio
+
+from base64 import urlsafe_b64encode
+from http import HTTPStatus
+
+from fastapi.param_functions import Query
+from fastapi.params import Depends
+from pydantic import BaseModel
+from starlette.exceptions import HTTPException
-from lnbits.core.services import pay_invoice, create_invoice
-from lnbits.core.crud import delete_expired_invoices
-from lnbits.decorators import api_validate_post_request
-from lnbits.settings import WALLET
from lnbits import bolt11
+from lnbits.core.crud import delete_expired_invoices, get_payments
+from lnbits.core.services import create_invoice, pay_invoice
+from lnbits.decorators import WalletTypeInfo
+from lnbits.settings import WALLET, LNBITS_SITE_TITLE
from . import lndhub_ext
-from .decorators import check_wallet
-from .utils import to_buffer, decoded_as_lndhub
+from .decorators import check_wallet, require_admin_key
+from .utils import decoded_as_lndhub, to_buffer
-@lndhub_ext.route("/ext/getinfo", methods=["GET"])
+@lndhub_ext.get("/ext/getinfo")
async def lndhub_getinfo():
- return jsonify({"error": True, "code": 1, "message": "bad auth"})
+ raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail="bad auth")
-@lndhub_ext.route("/ext/auth", methods=["POST"])
-@api_validate_post_request(
- schema={
- "login": {"type": "string", "required": True, "excludes": "refresh_token"},
- "password": {"type": "string", "required": True, "excludes": "refresh_token"},
- "refresh_token": {
- "type": "string",
- "required": True,
- "excludes": ["login", "password"],
- },
- }
-)
-async def lndhub_auth():
+class AuthData(BaseModel):
+ login: str = Query(None)
+ password: str = Query(None)
+ refresh_token: str = Query(None)
+
+
+@lndhub_ext.post("/ext/auth")
+async def lndhub_auth(data: AuthData):
token = (
- g.data["refresh_token"]
- if "refresh_token" in g.data and g.data["refresh_token"]
+ data.refresh_token
+ if data.refresh_token
else urlsafe_b64encode(
- (g.data["login"] + ":" + g.data["password"]).encode("utf-8")
+ (data.login + ":" + data.password).encode("utf-8")
).decode("ascii")
)
- return jsonify({"refresh_token": token, "access_token": token})
+ return {"refresh_token": token, "access_token": token}
-@lndhub_ext.route("/ext/addinvoice", methods=["POST"])
-@check_wallet()
-@api_validate_post_request(
- schema={
- "amt": {"type": "string", "required": True},
- "memo": {"type": "string", "required": True},
- "preimage": {"type": "string", "required": False},
- }
-)
-async def lndhub_addinvoice():
+class AddInvoice(BaseModel):
+ amt: str = Query(...)
+ memo: str = Query(...)
+ preimage: str = Query(None)
+
+
+@lndhub_ext.post("/ext/addinvoice")
+async def lndhub_addinvoice(
+ data: AddInvoice, wallet: WalletTypeInfo = Depends(check_wallet)
+):
try:
_, pr = await create_invoice(
- wallet_id=g.wallet.id,
- amount=int(g.data["amt"]),
- memo=g.data["memo"],
+ wallet_id=wallet.wallet.id,
+ amount=int(data.amt),
+ memo=data.memo or LNBITS_SITE_TITLE,
extra={"tag": "lndhub"},
)
- except Exception as e:
- return jsonify(
- {
- "error": True,
- "code": 7,
- "message": "Failed to create invoice: " + str(e),
- }
+ except:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Failed to create invoice"
)
-
invoice = bolt11.decode(pr)
- return jsonify(
- {
- "pay_req": pr,
- "payment_request": pr,
- "add_index": "500",
- "r_hash": to_buffer(invoice.payment_hash),
- "hash": invoice.payment_hash,
- }
- )
+ return {
+ "pay_req": pr,
+ "payment_request": pr,
+ "add_index": "500",
+ "r_hash": to_buffer(invoice.payment_hash),
+ "hash": invoice.payment_hash,
+ }
-@lndhub_ext.route("/ext/payinvoice", methods=["POST"])
-@check_wallet(requires_admin=True)
-@api_validate_post_request(schema={"invoice": {"type": "string", "required": True}})
-async def lndhub_payinvoice():
+class Invoice(BaseModel):
+ invoice: str = Query(...)
+
+
+@lndhub_ext.post("/ext/payinvoice")
+async def lndhub_payinvoice(
+ r_invoice: Invoice, wallet: WalletTypeInfo = Depends(require_admin_key)
+):
try:
await pay_invoice(
- wallet_id=g.wallet.id,
- payment_request=g.data["invoice"],
+ wallet_id=wallet.wallet.id,
+ payment_request=r_invoice.invoice,
extra={"tag": "lndhub"},
)
- except Exception as e:
- return jsonify(
- {
- "error": True,
- "code": 10,
- "message": "Payment failed: " + str(e),
- }
- )
+ except:
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Payment failed")
- invoice: bolt11.Invoice = bolt11.decode(g.data["invoice"])
- return jsonify(
- {
- "payment_error": "",
- "payment_preimage": "0" * 64,
- "route": {},
- "payment_hash": invoice.payment_hash,
- "decoded": decoded_as_lndhub(invoice),
- "fee_msat": 0,
- "type": "paid_invoice",
- "fee": 0,
- "value": invoice.amount_msat / 1000,
- "timestamp": int(time.time()),
- "memo": invoice.description,
- }
- )
+ invoice: bolt11.Invoice = bolt11.decode(r_invoice.invoice)
+
+ return {
+ "payment_error": "",
+ "payment_preimage": "0" * 64,
+ "route": {},
+ "payment_hash": invoice.payment_hash,
+ "decoded": decoded_as_lndhub(invoice),
+ "fee_msat": 0,
+ "type": "paid_invoice",
+ "fee": 0,
+ "value": invoice.amount_msat / 1000,
+ "timestamp": int(time.time()),
+ "memo": invoice.description,
+ }
-@lndhub_ext.route("/ext/balance", methods=["GET"])
-@check_wallet()
-async def lndhub_balance():
- return jsonify({"BTC": {"AvailableBalance": g.wallet.balance}})
+@lndhub_ext.get("/ext/balance")
+async def lndhub_balance(
+ wallet: WalletTypeInfo = Depends(check_wallet),
+):
+ return {"BTC": {"AvailableBalance": wallet.wallet.balance}}
-@lndhub_ext.route("/ext/gettxs", methods=["GET"])
-@check_wallet()
-async def lndhub_gettxs():
- for payment in await g.wallet.get_payments(
+@lndhub_ext.get("/ext/gettxs")
+async def lndhub_gettxs(
+ wallet: WalletTypeInfo = Depends(check_wallet),
+ limit: int = Query(20, ge=1, le=20),
+ offset: int = Query(0, ge=0),
+):
+ for payment in await get_payments(
+ wallet_id=wallet.wallet.id,
complete=False,
pending=True,
outgoing=True,
incoming=False,
+ limit=limit,
+ offset=offset,
exclude_uncheckable=True,
):
await payment.set_pending(
(await WALLET.get_payment_status(payment.checking_id)).pending
)
+ await asyncio.sleep(0.1)
- limit = int(request.args.get("limit", 200))
- return jsonify(
- [
- {
- "payment_preimage": payment.preimage,
- "payment_hash": payment.payment_hash,
- "fee_msat": payment.fee * 1000,
- "type": "paid_invoice",
- "fee": payment.fee,
- "value": int(payment.amount / 1000),
- "timestamp": payment.time,
- "memo": payment.memo
- if not payment.pending
- else "Payment in transition",
- }
- for payment in reversed(
- (
- await g.wallet.get_payments(
- pending=True, complete=True, outgoing=True, incoming=False
- )
- )[:limit]
+ return [
+ {
+ "payment_preimage": payment.preimage,
+ "payment_hash": payment.payment_hash,
+ "fee_msat": payment.fee * 1000,
+ "type": "paid_invoice",
+ "fee": payment.fee,
+ "value": int(payment.amount / 1000),
+ "timestamp": payment.time,
+ "memo": payment.memo if not payment.pending else "Payment in transition",
+ }
+ for payment in reversed(
+ (
+ await get_payments(
+ wallet_id=wallet.wallet.id,
+ pending=True,
+ complete=True,
+ outgoing=True,
+ incoming=False,
+ limit=limit,
+ offset=offset,
+ )
)
- ]
- )
+ )
+ ]
-@lndhub_ext.route("/ext/getuserinvoices", methods=["GET"])
-@check_wallet()
-async def lndhub_getuserinvoices():
- await delete_expired_invoices()
- for invoice in await g.wallet.get_payments(
+@lndhub_ext.get("/ext/getuserinvoices")
+async def lndhub_getuserinvoices(
+ wallet: WalletTypeInfo = Depends(check_wallet),
+ limit: int = Query(20, ge=1, le=20),
+ offset: int = Query(0, ge=0),
+):
+ for invoice in await get_payments(
+ wallet_id=wallet.wallet.id,
complete=False,
pending=True,
outgoing=False,
incoming=True,
+ limit=limit,
+ offset=offset,
exclude_uncheckable=True,
):
await invoice.set_pending(
(await WALLET.get_invoice_status(invoice.checking_id)).pending
)
+ await asyncio.sleep(0.1)
- limit = int(request.args.get("limit", 200))
- return jsonify(
- [
- {
- "r_hash": to_buffer(invoice.payment_hash),
- "payment_request": invoice.bolt11,
- "add_index": "500",
- "description": invoice.memo,
- "payment_hash": invoice.payment_hash,
- "ispaid": not invoice.pending,
- "amt": int(invoice.amount / 1000),
- "expire_time": int(time.time() + 1800),
- "timestamp": invoice.time,
- "type": "user_invoice",
- }
- for invoice in reversed(
- (
- await g.wallet.get_payments(
- pending=True, complete=True, incoming=True, outgoing=False
- )
- )[:limit]
+ return [
+ {
+ "r_hash": to_buffer(invoice.payment_hash),
+ "payment_request": invoice.bolt11,
+ "add_index": "500",
+ "description": invoice.memo,
+ "payment_hash": invoice.payment_hash,
+ "ispaid": not invoice.pending,
+ "amt": int(invoice.amount / 1000),
+ "expire_time": int(time.time() + 1800),
+ "timestamp": invoice.time,
+ "type": "user_invoice",
+ }
+ for invoice in reversed(
+ (
+ await get_payments(
+ wallet_id=wallet.wallet.id,
+ pending=True,
+ complete=True,
+ incoming=True,
+ outgoing=False,
+ limit=limit,
+ offset=offset,
+ )
)
- ]
- )
+ )
+ ]
-@lndhub_ext.route("/ext/getbtc", methods=["GET"])
-@check_wallet()
-async def lndhub_getbtc():
+@lndhub_ext.get("/ext/getbtc")
+async def lndhub_getbtc(wallet: WalletTypeInfo = Depends(check_wallet)):
"load an address for incoming onchain btc"
- return jsonify([])
+ return []
-@lndhub_ext.route("/ext/getpending", methods=["GET"])
-@check_wallet()
-async def lndhub_getpending():
+@lndhub_ext.get("/ext/getpending")
+async def lndhub_getpending(wallet: WalletTypeInfo = Depends(check_wallet)):
"pending onchain transactions"
- return jsonify([])
+ return []
-@lndhub_ext.route("/ext/decodeinvoice", methods=["GET"])
-async def lndhub_decodeinvoice():
- invoice = request.args.get("invoice")
+@lndhub_ext.get("/ext/decodeinvoice")
+async def lndhub_decodeinvoice(invoice: str = Query(None)):
inv = bolt11.decode(invoice)
- return jsonify(decoded_as_lndhub(inv))
+ return decoded_as_lndhub(inv)
-@lndhub_ext.route("/ext/checkrouteinvoice", methods=["GET"])
+@lndhub_ext.get("/ext/checkrouteinvoice")
async def lndhub_checkrouteinvoice():
"not implemented on canonical lndhub"
pass
diff --git a/lnbits/extensions/lnticket/__init__.py b/lnbits/extensions/lnticket/__init__.py
index cfdadc40..792b1175 100644
--- a/lnbits/extensions/lnticket/__init__.py
+++ b/lnbits/extensions/lnticket/__init__.py
@@ -1,17 +1,25 @@
-from quart import Blueprint
+import asyncio
+
+from fastapi import APIRouter
+
from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
db = Database("ext_lnticket")
-lnticket_ext: Blueprint = Blueprint(
- "lnticket", __name__, static_folder="static", template_folder="templates"
-)
+lnticket_ext: APIRouter = APIRouter(prefix="/lnticket", tags=["LNTicket"])
-from .views_api import * # noqa
+def lnticket_renderer():
+ return template_renderer(["lnbits/extensions/lnticket/templates"])
+
+
+from .tasks import wait_for_paid_invoices
from .views import * # noqa
-from .tasks import register_listeners
+from .views_api import * # noqa
-from lnbits.tasks import record_async
-lnticket_ext.record(record_async(register_listeners))
+def lnticket_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/lnticket/crud.py b/lnbits/extensions/lnticket/crud.py
index ed8c2271..8fe17090 100644
--- a/lnbits/extensions/lnticket/crud.py
+++ b/lnbits/extensions/lnticket/crud.py
@@ -1,27 +1,31 @@
+from lnbits.core.models import Wallet
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
-from .models import Tickets, Forms
+from .models import CreateFormData, CreateTicketData, Tickets, Forms
import httpx
async def create_ticket(
- payment_hash: str,
- wallet: str,
- form: str,
- name: str,
- email: str,
- ltext: str,
- sats: int,
+ payment_hash: str, wallet: str, data: CreateTicketData
) -> Tickets:
await db.execute(
"""
- INSERT INTO ticket (id, form, email, ltext, name, wallet, sats, paid)
+ INSERT INTO lnticket.ticket (id, form, email, ltext, name, wallet, sats, paid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
- (payment_hash, form, email, ltext, name, wallet, sats, False),
+ (
+ payment_hash,
+ data.form,
+ data.email,
+ data.ltext,
+ data.name,
+ wallet,
+ data.sats,
+ False,
+ ),
)
ticket = await get_ticket(payment_hash)
@@ -30,11 +34,13 @@ async def create_ticket(
async def set_ticket_paid(payment_hash: str) -> Tickets:
- row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (payment_hash,))
+ row = await db.fetchone(
+ "SELECT * FROM lnticket.ticket WHERE id = ?", (payment_hash,)
+ )
if row[7] == False:
await db.execute(
"""
- UPDATE ticket
+ UPDATE lnticket.ticket
SET paid = true
WHERE id = ?
""",
@@ -47,7 +53,7 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
amount = formdata.amountmade + row[7]
await db.execute(
"""
- UPDATE form
+ UPDATE lnticket.form2
SET amountmade = ?
WHERE id = ?
""",
@@ -77,7 +83,7 @@ async def set_ticket_paid(payment_hash: str) -> Tickets:
async def get_ticket(ticket_id: str) -> Optional[Tickets]:
- row = await db.fetchone("SELECT * FROM ticket WHERE id = ?", (ticket_id,))
+ row = await db.fetchone("SELECT * FROM lnticket.ticket WHERE id = ?", (ticket_id,))
return Tickets(**row) if row else None
@@ -87,51 +93,55 @@ async def get_tickets(wallet_ids: Union[str, List[str]]) -> List[Tickets]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
- f"SELECT * FROM ticket WHERE wallet IN ({q})", (*wallet_ids,)
+ f"SELECT * FROM lnticket.ticket WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Tickets(**row) for row in rows]
async def delete_ticket(ticket_id: str) -> None:
- await db.execute("DELETE FROM ticket WHERE id = ?", (ticket_id,))
+ await db.execute("DELETE FROM lnticket.ticket WHERE id = ?", (ticket_id,))
# FORMS
-async def create_form(
- *,
- wallet: str,
- name: str,
- webhook: Optional[str] = None,
- description: str,
- costpword: int,
-) -> Forms:
+async def create_form(data: CreateFormData, wallet: Wallet) -> Forms:
form_id = urlsafe_short_hash()
await db.execute(
"""
- INSERT INTO form (id, wallet, name, webhook, description, costpword, amountmade)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO lnticket.form2 (id, wallet, name, webhook, description, flatrate, amount, amountmade)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
- (form_id, wallet, name, webhook, description, costpword, 0),
+ (
+ form_id,
+ wallet.id,
+ wallet.name,
+ data.webhook,
+ data.description,
+ data.flatrate,
+ data.amount,
+ 0,
+ ),
)
form = await get_form(form_id)
- assert form, "Newly created form couldn't be retrieved"
+ assert form, "Newly created forms couldn't be retrieved"
return form
async def update_form(form_id: str, **kwargs) -> Forms:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
- await db.execute(f"UPDATE form SET {q} WHERE id = ?", (*kwargs.values(), form_id))
- row = await db.fetchone("SELECT * FROM form WHERE id = ?", (form_id,))
+ await db.execute(
+ f"UPDATE lnticket.form2 SET {q} WHERE id = ?", (*kwargs.values(), form_id)
+ )
+ row = await db.fetchone("SELECT * FROM lnticket.form2 WHERE id = ?", (form_id,))
assert row, "Newly updated form couldn't be retrieved"
return Forms(**row)
async def get_form(form_id: str) -> Optional[Forms]:
- row = await db.fetchone("SELECT * FROM form WHERE id = ?", (form_id,))
+ row = await db.fetchone("SELECT * FROM lnticket.form2 WHERE id = ?", (form_id,))
return Forms(**row) if row else None
@@ -141,11 +151,11 @@ async def get_forms(wallet_ids: Union[str, List[str]]) -> List[Forms]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
- f"SELECT * FROM form WHERE wallet IN ({q})", (*wallet_ids,)
+ f"SELECT * FROM lnticket.form2 WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Forms(**row) for row in rows]
async def delete_form(form_id: str) -> None:
- await db.execute("DELETE FROM form WHERE id = ?", (form_id,))
+ await db.execute("DELETE FROM lnticket.form2 WHERE id = ?", (form_id,))
diff --git a/lnbits/extensions/lnticket/migrations.py b/lnbits/extensions/lnticket/migrations.py
index 8ced65ef..44c2e0f1 100644
--- a/lnbits/extensions/lnticket/migrations.py
+++ b/lnbits/extensions/lnticket/migrations.py
@@ -2,21 +2,23 @@ async def m001_initial(db):
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS forms (
+ CREATE TABLE lnticket.forms (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
costpword INTEGER NOT NULL,
amountmade INTEGER NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS tickets (
+ CREATE TABLE lnticket.tickets (
id TEXT PRIMARY KEY,
form TEXT NOT NULL,
email TEXT NOT NULL,
@@ -24,7 +26,9 @@ async def m001_initial(db):
name TEXT NOT NULL,
wallet TEXT NOT NULL,
sats INTEGER NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
@@ -34,7 +38,7 @@ async def m002_changed(db):
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS ticket (
+ CREATE TABLE lnticket.ticket (
id TEXT PRIMARY KEY,
form TEXT NOT NULL,
email TEXT NOT NULL,
@@ -43,12 +47,16 @@ async def m002_changed(db):
wallet TEXT NOT NULL,
sats INTEGER NOT NULL,
paid BOOLEAN NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
- for row in [list(row) for row in await db.fetchall("SELECT * FROM tickets")]:
+ for row in [
+ list(row) for row in await db.fetchall("SELECT * FROM lnticket.tickets")
+ ]:
usescsv = ""
for i in range(row[5]):
@@ -59,7 +67,7 @@ async def m002_changed(db):
usescsv = usescsv[1:]
await db.execute(
"""
- INSERT INTO ticket (
+ INSERT INTO lnticket.ticket (
id,
form,
email,
@@ -71,25 +79,16 @@ async def m002_changed(db):
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
- (
- row[0],
- row[1],
- row[2],
- row[3],
- row[4],
- row[5],
- row[6],
- True,
- ),
+ (row[0], row[1], row[2], row[3], row[4], row[5], row[6], True),
)
- await db.execute("DROP TABLE tickets")
+ await db.execute("DROP TABLE lnticket.tickets")
async def m003_changed(db):
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS form (
+ CREATE TABLE IF NOT EXISTS lnticket.form (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
@@ -97,12 +96,14 @@ async def m003_changed(db):
description TEXT NOT NULL,
costpword INTEGER NOT NULL,
amountmade INTEGER NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
- for row in [list(row) for row in await db.fetchall("SELECT * FROM forms")]:
+ for row in [list(row) for row in await db.fetchall("SELECT * FROM lnticket.forms")]:
usescsv = ""
for i in range(row[5]):
@@ -113,7 +114,7 @@ async def m003_changed(db):
usescsv = usescsv[1:]
await db.execute(
"""
- INSERT INTO form (
+ INSERT INTO lnticket.form (
id,
wallet,
name,
@@ -124,14 +125,53 @@ async def m003_changed(db):
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
- (
- row[0],
- row[1],
- row[2],
- row[3],
- row[4],
- row[5],
- row[6],
- ),
+ (row[0], row[1], row[2], row[3], row[4], row[5], row[6]),
)
- await db.execute("DROP TABLE forms")
+ await db.execute("DROP TABLE lnticket.forms")
+
+
+async def m004_changed(db):
+
+ await db.execute(
+ """
+ CREATE TABLE lnticket.form2 (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ name TEXT NOT NULL,
+ webhook TEXT,
+ description TEXT NOT NULL,
+ flatrate INTEGER DEFAULT 0,
+ amount INTEGER NOT NULL,
+ amountmade INTEGER NOT NULL,
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
+ );
+ """
+ )
+
+ for row in [list(row) for row in await db.fetchall("SELECT * FROM lnticket.form")]:
+ usescsv = ""
+
+ for i in range(row[5]):
+ if row[7]:
+ usescsv += "," + str(i + 1)
+ else:
+ usescsv += "," + str(1)
+ usescsv = usescsv[1:]
+ await db.execute(
+ """
+ INSERT INTO lnticket.form2 (
+ id,
+ wallet,
+ name,
+ webhook,
+ description,
+ amount,
+ amountmade
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (row[0], row[1], row[2], row[3], row[4], row[5], row[6]),
+ )
+ await db.execute("DROP TABLE lnticket.form")
diff --git a/lnbits/extensions/lnticket/models.py b/lnbits/extensions/lnticket/models.py
index 362bc223..50ffc1e1 100644
--- a/lnbits/extensions/lnticket/models.py
+++ b/lnbits/extensions/lnticket/models.py
@@ -1,18 +1,37 @@
-from typing import NamedTuple
+from typing import Optional
+from fastapi.param_functions import Query
+from pydantic import BaseModel
-class Forms(NamedTuple):
+class CreateFormData(BaseModel):
+ name: str = Query(...)
+ webhook: str = Query(None)
+ description: str = Query(..., min_length=0)
+ amount: int = Query(..., ge=0)
+ flatrate: int = Query(...)
+
+
+class CreateTicketData(BaseModel):
+ form: str = Query(...)
+ name: str = Query(...)
+ email: str = Query("")
+ ltext: str = Query(...)
+ sats: int = Query(..., ge=0)
+
+
+class Forms(BaseModel):
id: str
wallet: str
name: str
- webhook: str
+ webhook: Optional[str]
description: str
- costpword: int
+ amount: int
+ flatrate: int
amountmade: int
time: int
-class Tickets(NamedTuple):
+class Tickets(BaseModel):
id: str
form: str
email: str
diff --git a/lnbits/extensions/lnticket/tasks.py b/lnbits/extensions/lnticket/tasks.py
index 41395389..2d79fb51 100644
--- a/lnbits/extensions/lnticket/tasks.py
+++ b/lnbits/extensions/lnticket/tasks.py
@@ -1,25 +1,20 @@
-import json
-import trio # type: ignore
+import asyncio
from lnbits.core.models import Payment
-from lnbits.core.crud import create_payment
-from lnbits.core import db as core_db
-from lnbits.tasks import register_invoice_listener, internal_invoice_paid
-from lnbits.helpers import urlsafe_short_hash
+from lnbits.tasks import register_invoice_listener
from .crud import get_ticket, set_ticket_paid
-async def register_listeners():
- invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
- register_invoice_listener(invoice_paid_chan_send)
- await wait_for_paid_invoices(invoice_paid_chan_recv)
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
-
-async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
- async for payment in invoice_paid_chan:
+ while True:
+ payment = await invoice_queue.get()
await on_invoice_paid(payment)
+
async def on_invoice_paid(payment: Payment) -> None:
if "lnticket" != payment.extra.get("tag"):
# not a lnticket invoice
@@ -32,5 +27,3 @@ async def on_invoice_paid(payment: Payment) -> None:
await payment.set_pending(False)
await set_ticket_paid(payment.payment_hash)
- _ticket = await get_ticket(payment.checking_id)
- print('ticket', _ticket)
diff --git a/lnbits/extensions/lnticket/templates/lnticket/display.html b/lnbits/extensions/lnticket/templates/lnticket/display.html
index b432ce9e..3b48766c 100644
--- a/lnbits/extensions/lnticket/templates/lnticket/display.html
+++ b/lnbits/extensions/lnticket/templates/lnticket/display.html
@@ -23,17 +23,26 @@
label="Your email (optional, if you want a reply)"
>
+
{% raw %}{{amountWords}}{% endraw %}
Submit {
- this.receive = {
- show: false,
- status: 'complete',
- paymentReq: null
- }
- dismissMsg()
-
- this.formDialog.data.name = ''
- this.formDialog.data.email = ''
- this.formDialog.data.text = ''
- this.$q.notify({
- type: 'positive',
- message: 'Sent, thank you!',
- icon: 'thumb_up'
- })
- }
- )
- },
Invoice: function () {
var self = this
+ var dialog = this.formDialog
axios
.post('/lnticket/api/v1/tickets/{{ form_id }}', {
form: '{{ form_id }}',
@@ -175,16 +160,34 @@
self.paymentReq = response.data.payment_request
self.paymentCheck = response.data.payment_hash
- dismissMsg = self.$q.notify({
+ dialog.dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
-
self.receive = {
show: true,
status: 'pending',
paymentReq: self.paymentReq
}
+ dialog.paymentChecker = setInterval(function () {
+ axios
+ .get('/lnticket/api/v1/tickets/' + response.data.payment_hash)
+ .then(function (res) {
+ if (res.data.paid) {
+ clearInterval(dialog.paymentChecker)
+ dialog.dismissMsg()
+ self.receive.show = false
+ self.formDialog.data.name = ''
+ self.formDialog.data.email = ''
+ self.formDialog.data.text = ''
+ self.$q.notify({
+ type: 'positive',
+ message: 'Sats received, thanks!',
+ icon: 'thumb_up'
+ })
+ }
+ })
+ }, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
@@ -193,7 +196,6 @@
},
created() {
this.wallet.inkey = '{{form_wallet}}'
- this.startPaymentNotifier()
}
})
diff --git a/lnbits/extensions/lnticket/templates/lnticket/index.html b/lnbits/extensions/lnticket/templates/lnticket/index.html
index d2ab7801..6572d98a 100644
--- a/lnbits/extensions/lnticket/templates/lnticket/index.html
+++ b/lnbits/extensions/lnticket/templates/lnticket/index.html
@@ -4,7 +4,7 @@
- New Form
@@ -117,9 +117,10 @@
{% raw %}
+
- {{ col.label }}
+ {{ col.label }}
@@ -136,9 +137,19 @@
:href="'mailto:' + props.row.email"
>
+
+ Click to show ticket
+
- {{ col.value }}
+ {{ col.label == "Ticket" ? col.value.length > 20 ? `${col.value.substring(0, 20)}...` : col.value : col.value }}
@@ -162,7 +173,7 @@
- LNbits Support Tickets extension
+ {{SITE_TITLE}} Support Tickets extension
@@ -206,18 +217,30 @@
type="textarea"
label="Description "
>
-
+
+
+
+
+
+
+
+
+
Update Form
@@ -225,8 +248,8 @@
Create Form
@@ -237,6 +260,29 @@
+
+
+
+ {% raw %}
+
+
+ {{this.ticketDialog.data.name}} sent a ticket
+
+
+ {{this.ticketDialog.data.email}}
+
+ {{this.ticketDialog.data.date}}
+
+
+
+ {{this.ticketDialog.data.content}}
+
+ {% endraw %}
+
+
+
+
+
{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+ {% endblock %}
+
diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html
new file mode 100644
index 00000000..b51e2556
--- /dev/null
+++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html
@@ -0,0 +1,534 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+ {% raw %}
+ New LNURLDevice instance
+
+
+
+
+
+
+
+
+ lNURLdevice
+
+
+
+
+
+
+
+
+ Export to CSV
+
+
+
+
+
+
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+ Delete LNURLDevice
+
+
+
+
+ LNURLDevice Settings
+
+
+
+
+ {{ col.value }}
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} LNURLDevice Extension
+
+
+
+
+ {% include "lnurldevice/_api_docs.html" %}
+
+
+
+
+
+
+ LNURLDevice device string
+ {% raw
+ %}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}},
+ {{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw
+ %} Click to copy URL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Update lnurldevice
+ Create lnurldevice
+ Cancel
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+
+{% endblock %}
diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/paid.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/paid.html
new file mode 100644
index 00000000..c185ecce
--- /dev/null
+++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/paid.html
@@ -0,0 +1,27 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+
+ {{ pin }}
+
+
+
+
+
+
+ {% endblock %} {% block scripts %}
+
+
+
+ {% endblock %}
+
diff --git a/lnbits/extensions/lnurldevice/views.py b/lnbits/extensions/lnurldevice/views.py
new file mode 100644
index 00000000..3389e17c
--- /dev/null
+++ b/lnbits/extensions/lnurldevice/views.py
@@ -0,0 +1,53 @@
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.param_functions import Query
+from fastapi.params import Depends
+from fastapi.templating import Jinja2Templates
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse
+
+from lnbits.core.crud import update_payment_status
+from lnbits.core.models import User
+from lnbits.core.views.api import api_payment
+from lnbits.decorators import check_user_exists
+
+from . import lnurldevice_ext, lnurldevice_renderer
+from .crud import get_lnurldevice, get_lnurldevicepayment
+
+templates = Jinja2Templates(directory="templates")
+
+
+@lnurldevice_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return lnurldevice_renderer().TemplateResponse(
+ "lnurldevice/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@lnurldevice_ext.get(
+ "/{paymentid}", name="lnurldevice.displaypin", response_class=HTMLResponse
+)
+async def displaypin(request: Request, paymentid: str = Query(None)):
+ lnurldevicepayment = await get_lnurldevicepayment(paymentid)
+ if not lnurldevicepayment:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="No lmurldevice payment"
+ )
+ device = await get_lnurldevice(lnurldevicepayment.deviceid)
+ if not device:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="lnurldevice not found."
+ )
+ status = await api_payment(lnurldevicepayment.payhash)
+ if status["paid"]:
+ await update_payment_status(
+ checking_id=lnurldevicepayment.payhash, pending=True
+ )
+ return lnurldevice_renderer().TemplateResponse(
+ "lnurldevice/paid.html", {"request": request, "pin": lnurldevicepayment.pin}
+ )
+ return lnurldevice_renderer().TemplateResponse(
+ "lnurldevice/error.html",
+ {"request": request, "pin": "filler", "not_paid": True},
+ )
diff --git a/lnbits/extensions/lnurldevice/views_api.py b/lnbits/extensions/lnurldevice/views_api.py
new file mode 100644
index 00000000..d152d210
--- /dev/null
+++ b/lnbits/extensions/lnurldevice/views_api.py
@@ -0,0 +1,88 @@
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.param_functions import Query
+from fastapi.params import Depends
+from starlette.exceptions import HTTPException
+
+from lnbits.core.crud import get_user
+from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
+from lnbits.extensions.lnurldevice import lnurldevice_ext
+from lnbits.utils.exchange_rates import currencies
+
+from . import lnurldevice_ext
+from .crud import (
+ create_lnurldevice,
+ delete_lnurldevice,
+ get_lnurldevice,
+ get_lnurldevices,
+ update_lnurldevice,
+)
+from .models import createLnurldevice
+
+
+@lnurldevice_ext.get("/api/v1/currencies")
+async def api_list_currencies_available():
+ return list(currencies.keys())
+
+
+#######################lnurldevice##########################
+
+
+@lnurldevice_ext.post("/api/v1/lnurlpos")
+@lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}")
+async def api_lnurldevice_create_or_update(
+ data: createLnurldevice,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+ lnurldevice_id: str = Query(None),
+):
+ if not lnurldevice_id:
+ lnurldevice = await create_lnurldevice(data)
+ return lnurldevice.dict()
+ else:
+ lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id)
+ return lnurldevice.dict()
+
+
+@lnurldevice_ext.get("/api/v1/lnurlpos")
+async def api_lnurldevices_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
+ wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
+ try:
+ return [
+ {**lnurldevice.dict()} for lnurldevice in await get_lnurldevices(wallet_ids)
+ ]
+ except:
+ return ""
+
+
+@lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}")
+async def api_lnurldevice_retrieve(
+ request: Request,
+ wallet: WalletTypeInfo = Depends(get_key_type),
+ lnurldevice_id: str = Query(None),
+):
+ lnurldevice = await get_lnurldevice(lnurldevice_id)
+ if not lnurldevice:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="lnurldevice does not exist"
+ )
+ if not lnurldevice.lnurl_toggle:
+ return {**lnurldevice.dict()}
+ return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(request=request)}}
+
+
+@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}")
+async def api_lnurldevice_delete(
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+ lnurldevice_id: str = Query(None),
+):
+ lnurldevice = await get_lnurldevice(lnurldevice_id)
+
+ if not lnurldevice:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Wallet link does not exist."
+ )
+
+ await delete_lnurldevice(lnurldevice_id)
+
+ return "", HTTPStatus.NO_CONTENT
diff --git a/lnbits/extensions/lnurlp/__init__.py b/lnbits/extensions/lnurlp/__init__.py
index d820b197..40981748 100644
--- a/lnbits/extensions/lnurlp/__init__.py
+++ b/lnbits/extensions/lnurlp/__init__.py
@@ -1,18 +1,35 @@
-from quart import Blueprint
+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_lnurlp")
-lnurlp_ext: Blueprint = Blueprint(
- "lnurlp", __name__, static_folder="static", template_folder="templates"
-)
+lnurlp_static_files = [
+ {
+ "path": "/lnurlp/static",
+ "app": StaticFiles(directory="lnbits/extensions/lnurlp/static"),
+ "name": "lnurlp_static",
+ }
+]
+
+lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])
+
+
+def lnurlp_renderer():
+ return template_renderer(["lnbits/extensions/lnurlp/templates"])
-from .views_api import * # noqa
-from .views import * # noqa
from .lnurl import * # noqa
-from .tasks import register_listeners
+from .tasks import wait_for_paid_invoices
+from .views import * # noqa
+from .views_api import * # noqa
-from lnbits.tasks import record_async
-lnurlp_ext.record(record_async(register_listeners))
+def lnurlp_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/lnurlp/crud.py b/lnbits/extensions/lnurlp/crud.py
index 9c2b182a..4215faf6 100644
--- a/lnbits/extensions/lnurlp/crud.py
+++ b/lnbits/extensions/lnurlp/crud.py
@@ -1,24 +1,17 @@
from typing import List, Optional, Union
+from lnbits.db import SQLITE
from . import db
-from .models import PayLink
+from .models import PayLink, CreatePayLinkData
-async def create_pay_link(
- *,
- wallet_id: str,
- description: str,
- min: int,
- max: int,
- comment_chars: int = 0,
- currency: Optional[str] = None,
- webhook_url: Optional[str] = None,
- success_text: Optional[str] = None,
- success_url: Optional[str] = None,
-) -> PayLink:
- result = await db.execute(
- """
- INSERT INTO pay_links (
+async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
+
+ returning = "" if db.type == SQLITE else "RETURNING ID"
+ method = db.execute if db.type == SQLITE else db.fetchone
+ result = await (method)(
+ f"""
+ INSERT INTO lnurlp.pay_links (
wallet,
description,
min,
@@ -32,27 +25,32 @@ async def create_pay_link(
currency
)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
+ {returning}
""",
(
wallet_id,
- description,
- min,
- max,
- webhook_url,
- success_text,
- success_url,
- comment_chars,
- currency,
+ data.description,
+ data.min,
+ data.max,
+ data.webhook_url,
+ data.success_text,
+ data.success_url,
+ data.comment_chars,
+ data.currency,
),
)
- link_id = result._result_proxy.lastrowid
+ if db.type == SQLITE:
+ link_id = result._result_proxy.lastrowid
+ else:
+ link_id = result[0]
+
link = await get_pay_link(link_id)
assert link, "Newly created link couldn't be retrieved"
return link
async def get_pay_link(link_id: int) -> Optional[PayLink]:
- row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
+ row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
@@ -63,32 +61,31 @@ async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"""
- SELECT * FROM pay_links WHERE wallet IN ({q})
+ SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q})
ORDER BY Id
""",
(*wallet_ids,),
)
-
return [PayLink.from_row(row) for row in rows]
async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
- f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
+ f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
)
- row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
+ row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
await db.execute(
- f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
+ f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
)
- row = await db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
+ row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
async def delete_pay_link(link_id: int) -> None:
- await db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))
+ await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,))
diff --git a/lnbits/extensions/lnurlp/lnurl.py b/lnbits/extensions/lnurlp/lnurl.py
index 936d51f3..173b4823 100644
--- a/lnbits/extensions/lnurlp/lnurl.py
+++ b/lnbits/extensions/lnurlp/lnurl.py
@@ -1,8 +1,14 @@
import hashlib
import math
from http import HTTPStatus
-from quart import jsonify, url_for, request
-from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
+
+from fastapi import Request
+from lnurl import ( # type: ignore
+ LnurlErrorResponse,
+ LnurlPayActionResponse,
+ LnurlPayResponse,
+)
+from starlette.exceptions import HTTPException
from lnbits.core.services import create_invoice
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
@@ -11,18 +17,22 @@ from . import lnurlp_ext
from .crud import increment_pay_link
-@lnurlp_ext.route("/api/v1/lnurl/", methods=["GET"])
-async def api_lnurl_response(link_id):
+@lnurlp_ext.get(
+ "/api/v1/lnurl/{link_id}",
+ status_code=HTTPStatus.OK,
+ name="lnurlp.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:
- return (
- jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}),
- HTTPStatus.OK,
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
)
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
+
resp = LnurlPayResponse(
- callback=url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True),
+ callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
min_sendable=math.ceil(link.min * rate) * 1000,
max_sendable=round(link.max * rate) * 1000,
metadata=link.lnurlpay_metadata,
@@ -32,18 +42,20 @@ async def api_lnurl_response(link_id):
if link.comment_chars > 0:
params["commentAllowed"] = link.comment_chars
- return jsonify(params), HTTPStatus.OK
+ return params
-@lnurlp_ext.route("/api/v1/lnurl/cb/", methods=["GET"])
-async def api_lnurl_callback(link_id):
+@lnurlp_ext.get(
+ "/api/v1/lnurl/cb/{link_id}",
+ status_code=HTTPStatus.OK,
+ name="lnurlp.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:
- return (
- jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}),
- HTTPStatus.OK,
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Pay 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:
@@ -54,36 +66,22 @@ async def api_lnurl_callback(link_id):
min = link.min * 1000
max = link.max * 1000
- amount_received = int(request.args.get("amount") or 0)
+ amount_received = int(request.query_params.get("amount") or 0)
if amount_received < min:
- return (
- jsonify(
- LnurlErrorResponse(
- reason=f"Amount {amount_received} is smaller than minimum {min}."
- ).dict()
- ),
- HTTPStatus.OK,
- )
- elif amount_received > max:
- return (
- jsonify(
- LnurlErrorResponse(
- reason=f"Amount {amount_received} is greater than maximum {max}."
- ).dict()
- ),
- HTTPStatus.OK,
- )
+ return LnurlErrorResponse(
+ reason=f"Amount {amount_received} is smaller than minimum {min}."
+ ).dict()
- comment = request.args.get("comment")
+ 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 (
- jsonify(
- LnurlErrorResponse(
- reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
- ).dict()
- ),
- HTTPStatus.OK,
- )
+ 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,
@@ -92,20 +90,20 @@ async def api_lnurl_callback(link_id):
description_hash=hashlib.sha256(
link.lnurlpay_metadata.encode("utf-8")
).digest(),
- extra={"tag": "lnurlp", "link": link.id, "comment": comment},
+ extra={
+ "tag": "lnurlp",
+ "link": link.id,
+ "comment": comment,
+ "extra": request.query_params.get("amount"),
+ },
)
success_action = link.success_action(payment_hash)
if success_action:
resp = LnurlPayActionResponse(
- pr=payment_request,
- success_action=success_action,
- routes=[],
+ pr=payment_request, success_action=success_action, routes=[]
)
else:
- resp = LnurlPayActionResponse(
- pr=payment_request,
- routes=[],
- )
+ resp = LnurlPayActionResponse(pr=payment_request, routes=[])
- return jsonify(resp.dict()), HTTPStatus.OK
+ return resp.dict()
diff --git a/lnbits/extensions/lnurlp/migrations.py b/lnbits/extensions/lnurlp/migrations.py
index b7ebd3f1..428bde2c 100644
--- a/lnbits/extensions/lnurlp/migrations.py
+++ b/lnbits/extensions/lnurlp/migrations.py
@@ -3,9 +3,9 @@ async def m001_initial(db):
Initial pay table.
"""
await db.execute(
- """
- CREATE TABLE IF NOT EXISTS pay_links (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
+ f"""
+ CREATE TABLE lnurlp.pay_links (
+ id {db.serial_primary_key},
wallet TEXT NOT NULL,
description TEXT NOT NULL,
amount INTEGER NOT NULL,
@@ -20,13 +20,13 @@ async def m002_webhooks_and_success_actions(db):
"""
Webhooks and success actions.
"""
- await db.execute("ALTER TABLE pay_links ADD COLUMN webhook_url TEXT;")
- await db.execute("ALTER TABLE pay_links ADD COLUMN success_text TEXT;")
- await db.execute("ALTER TABLE pay_links ADD COLUMN success_url TEXT;")
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_url TEXT;")
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_text TEXT;")
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;")
await db.execute(
- """
- CREATE TABLE invoices (
- pay_link INTEGER NOT NULL REFERENCES pay_links (id),
+ f"""
+ CREATE TABLE lnurlp.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
@@ -41,12 +41,12 @@ async def m003_min_max_comment_fiat(db):
converted automatically to satoshis based on some API.
"""
await db.execute(
- "ALTER TABLE pay_links ADD COLUMN currency TEXT;"
+ "ALTER TABLE lnurlp.pay_links ADD COLUMN currency TEXT;"
) # null = satoshis
await db.execute(
- "ALTER TABLE pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;"
+ "ALTER TABLE lnurlp.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;"
)
- await db.execute("ALTER TABLE pay_links RENAME COLUMN amount TO min;")
- await db.execute("ALTER TABLE pay_links ADD COLUMN max INTEGER;")
- await db.execute("UPDATE pay_links SET max = min;")
- await db.execute("DROP TABLE invoices")
+ await db.execute("ALTER TABLE lnurlp.pay_links RENAME COLUMN amount TO min;")
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;")
+ await db.execute("UPDATE lnurlp.pay_links SET max = min;")
+ await db.execute("DROP TABLE lnurlp.invoices")
diff --git a/lnbits/extensions/lnurlp/models.py b/lnbits/extensions/lnurlp/models.py
index e02d7ea6..6fc9cc1a 100644
--- a/lnbits/extensions/lnurlp/models.py
+++ b/lnbits/extensions/lnurlp/models.py
@@ -1,23 +1,36 @@
import json
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
-from quart import url_for
-from typing import NamedTuple, Optional, Dict
-from sqlite3 import Row
-from lnurl import Lnurl, encode as lnurl_encode # type: ignore
+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 LnurlPayMetadata # type: ignore
+from sqlite3 import Row
+from pydantic import BaseModel
-class PayLink(NamedTuple):
+class CreatePayLinkData(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 PayLink(BaseModel):
id: int
wallet: str
description: str
min: int
served_meta: int
served_pr: int
- webhook_url: str
- success_text: str
- success_url: str
- currency: str
+ webhook_url: Optional[str]
+ success_text: Optional[str]
+ success_url: Optional[str]
+ currency: Optional[str]
comment_chars: int
max: int
@@ -26,9 +39,8 @@ class PayLink(NamedTuple):
data = dict(row)
return cls(**data)
- @property
- def lnurl(self) -> Lnurl:
- url = url_for("lnurlp.api_lnurl_response", link_id=self.id, _external=True)
+ def lnurl(self, req: Request) -> str:
+ url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
return lnurl_encode(url)
@property
@@ -47,9 +59,6 @@ class PayLink(NamedTuple):
"url": urlunparse(url),
}
elif self.success_text:
- return {
- "tag": "message",
- "message": self.success_text,
- }
+ return {"tag": "message", "message": self.success_text}
else:
return None
diff --git a/lnbits/extensions/lnurlp/static/js/index.js b/lnbits/extensions/lnurlp/static/js/index.js
index efd0fbd8..e18d6161 100644
--- a/lnbits/extensions/lnurlp/static/js/index.js
+++ b/lnbits/extensions/lnurlp/static/js/index.js
@@ -51,7 +51,7 @@ new Vue({
LNbits.api
.request(
'GET',
- '/lnurlp/api/v1/links?all_wallets',
+ '/lnurlp/api/v1/links?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(response => {
@@ -166,7 +166,7 @@ new Vue({
LNbits.api
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
.then(response => {
- this.payLinks.push(mapPayLink(response.data))
+ this.getPayLinks()
this.formDialog.show = false
this.resetFormData()
})
diff --git a/lnbits/extensions/lnurlp/tasks.py b/lnbits/extensions/lnurlp/tasks.py
index e8d6a453..b632fa13 100644
--- a/lnbits/extensions/lnurlp/tasks.py
+++ b/lnbits/extensions/lnurlp/tasks.py
@@ -1,4 +1,4 @@
-import trio
+import asyncio
import json
import httpx
@@ -9,14 +9,12 @@ from lnbits.tasks import register_invoice_listener
from .crud import get_pay_link
-async def register_listeners():
- invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
- register_invoice_listener(invoice_paid_chan_send)
- await wait_for_paid_invoices(invoice_paid_chan_recv)
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
-
-async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
- async for payment in invoice_paid_chan:
+ while True:
+ payment = await invoice_queue.get()
await on_invoice_paid(payment)
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html
index 68cea8f8..80d1478d 100644
--- a/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html
+++ b/lnbits/extensions/lnurlp/templates/lnurlp/_api_docs.html
@@ -7,7 +7,7 @@
- GET /api/v1/links
+ GET /lnurlp/api/v1/links
Headers
{"X-Api-Key": <invoice_key>}
Body (application/json)
@@ -17,8 +17,8 @@
[<pay_link_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v1/links -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}api/v1/links -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -27,7 +27,8 @@
GET /api/v1/links/<pay_id>GET
+ /lnurlp/api/v1/links/<pay_id>
Headers
{"X-Api-Key": <invoice_key>}
@@ -38,8 +39,8 @@
{"lnurl": <string>}
Curl example
curl -X GET {{ request.url_root }}api/v1/links/<pay_id> -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}api/v1/links/<pay_id> -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -52,20 +53,26 @@
>
- POST /api/v1/links
+ POST /lnurlp/api/v1/links
Headers
{"X-Api-Key": <admin_key>}
Body (application/json)
- {"description": <string> "amount": <integer>}
+ {"description": <string> "amount": <integer> "max":
+ <integer> "min": <integer> "comment_chars":
+ <integer>}
Returns 201 CREATED (application/json)
{"lnurl": <string>}
Curl example
curl -X POST {{ request.url_root }}api/v1/links -d '{"description":
- <string>, "amount": <integer>}' -H "Content-type:
- application/json" -H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
+ >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 }}"
@@ -80,7 +87,7 @@
PUT
- /api/v1/links/<pay_id>
Headers
{"X-Api-Key": <admin_key>}
@@ -92,10 +99,10 @@
{"lnurl": <string>}
Curl example
curl -X PUT {{ request.url_root }}api/v1/links/<pay_id> -d
+ >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: {{
- g.user.wallets[0].adminkey }}"
+ user.wallets[0].adminkey }}"
@@ -111,7 +118,7 @@
DELETE
- /api/v1/links/<pay_id>
Headers
{"X-Api-Key": <admin_key>}
@@ -119,8 +126,8 @@
Curl example
curl -X DELETE {{ request.url_root }}api/v1/links/<pay_id> -H
- "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
+ >curl -X DELETE {{ request.base_url }}api/v1/links/<pay_id> -H
+ "X-Api-Key: {{ user.wallets[0].adminkey }}"
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/display.html b/lnbits/extensions/lnurlp/templates/lnurlp/display.html
index a2e0389c..08e4de15 100644
--- a/lnbits/extensions/lnurlp/templates/lnurlp/display.html
+++ b/lnbits/extensions/lnurlp/templates/lnurlp/display.html
@@ -4,10 +4,10 @@
- Copy LNURL
diff --git a/lnbits/extensions/lnurlp/templates/lnurlp/index.html b/lnbits/extensions/lnurlp/templates/lnurlp/index.html
index c7d60667..c535f2fb 100644
--- a/lnbits/extensions/lnurlp/templates/lnurlp/index.html
+++ b/lnbits/extensions/lnurlp/templates/lnurlp/index.html
@@ -4,7 +4,7 @@
- New pay link
@@ -120,7 +120,9 @@
- LNbits LNURL-pay extension
+
+ {{SITE_TITLE}} LNURL-pay extension
+
@@ -227,14 +229,14 @@
Update pay link
-
+
{% endblock %} {% block styles %}
diff --git a/lnbits/extensions/lnurlp/views.py b/lnbits/extensions/lnurlp/views.py
index 72f30c13..4e9f487c 100644
--- a/lnbits/extensions/lnurlp/views.py
+++ b/lnbits/extensions/lnurlp/views.py
@@ -1,32 +1,44 @@
-from quart import g, abort, render_template
from http import HTTPStatus
-from lnbits.decorators import check_user_exists, validate_uuids
+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 . import lnurlp_ext
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import lnurlp_ext, lnurlp_renderer
from .crud import get_pay_link
-
-@lnurlp_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("lnurlp/index.html", user=g.user)
+templates = Jinja2Templates(directory="templates")
-@lnurlp_ext.route("/")
-async def display(link_id):
+@lnurlp_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return lnurlp_renderer().TemplateResponse(
+ "lnurlp/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@lnurlp_ext.get("/{link_id}", response_class=HTMLResponse)
+async def display(request: Request, link_id):
link = await get_pay_link(link_id)
if not link:
- abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
-
- return await render_template("lnurlp/display.html", link=link)
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
+ )
+ ctx = {"request": request, "lnurl": link.lnurl(req=request)}
+ return lnurlp_renderer().TemplateResponse("lnurlp/display.html", ctx)
-@lnurlp_ext.route("/print/")
-async def print_qr(link_id):
+@lnurlp_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:
- abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
-
- return await render_template("lnurlp/print_qr.html", link=link)
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
+ )
+ ctx = {"request": request, "lnurl": link.lnurl(req=request)}
+ return lnurlp_renderer().TemplateResponse("lnurlp/print_qr.html", ctx)
diff --git a/lnbits/extensions/lnurlp/views_api.py b/lnbits/extensions/lnurlp/views_api.py
index af670c83..ff6e96e2 100644
--- a/lnbits/extensions/lnurlp/views_api.py
+++ b/lnbits/extensions/lnurlp/views_api.py
@@ -1,142 +1,146 @@
-from quart import g, jsonify, request
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 api_check_wallet_key, api_validate_post_request
+from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.utils.exchange_rates import currencies, get_fiat_rate_satoshis
from . import lnurlp_ext
from .crud import (
create_pay_link,
+ delete_pay_link,
get_pay_link,
get_pay_links,
update_pay_link,
- delete_pay_link,
)
+from .models import CreatePayLinkData
-@lnurlp_ext.route("/api/v1/currencies", methods=["GET"])
+@lnurlp_ext.get("/api/v1/currencies")
async def api_list_currencies_available():
- return jsonify(list(currencies.keys()))
+ return list(currencies.keys())
-@lnurlp_ext.route("/api/v1/links", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_links():
- wallet_ids = [g.wallet.id]
+@lnurlp_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" in request.args:
- wallet_ids = (await get_user(g.wallet.user)).wallet_ids
+ if all_wallets:
+ wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
try:
- return (
- jsonify(
- [
- {**link._asdict(), **{"lnurl": link.lnurl}}
- for link in await get_pay_links(wallet_ids)
- ]
- ),
- HTTPStatus.OK,
- )
+ return [
+ {**link.dict(), "lnurl": link.lnurl(req)}
+ for link in await get_pay_links(wallet_ids)
+ ]
+
except LnurlInvalidUrl:
- return (
- jsonify(
- {
- "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
- }
- ),
- HTTPStatus.UPGRADE_REQUIRED,
+ raise HTTPException(
+ status_code=HTTPStatus.UPGRADE_REQUIRED,
+ detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
)
-@lnurlp_ext.route("/api/v1/links/", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_link_retrieve(link_id):
+@lnurlp_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:
- return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
+ raise HTTPException(
+ detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
+ )
- if link.wallet != g.wallet.id:
- return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
+ if link.wallet != wallet.wallet.id:
+ raise HTTPException(
+ detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
+ )
- return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK
+ return {**link.dict(), **{"lnurl": link.lnurl(r)}}
-@lnurlp_ext.route("/api/v1/links", methods=["POST"])
-@lnurlp_ext.route("/api/v1/links/", methods=["PUT"])
-@api_check_wallet_key("invoice")
-@api_validate_post_request(
- schema={
- "description": {"type": "string", "empty": False, "required": True},
- "min": {"type": "number", "min": 0.01, "required": True},
- "max": {"type": "number", "min": 0.01, "required": True},
- "currency": {"type": "string", "nullable": True, "required": False},
- "comment_chars": {"type": "integer", "required": True, "min": 0, "max": 800},
- "webhook_url": {"type": "string", "required": False},
- "success_text": {"type": "string", "required": False},
- "success_url": {"type": "string", "required": False},
- }
-)
-async def api_link_create_or_update(link_id=None):
- if g.data["min"] > g.data["max"]:
- return jsonify({"message": "Min is greater than max."}), HTTPStatus.BAD_REQUEST
+@lnurlp_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
+@lnurlp_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
+async def api_link_create_or_update(
+ data: CreatePayLinkData,
+ 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 g.data.get("currency") == None and (
- round(g.data["min"]) != g.data["min"] or round(g.data["max"]) != g.data["max"]
+ 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
):
- return jsonify({"message": "Must use full satoshis."}), HTTPStatus.BAD_REQUEST
+ raise HTTPException(
+ detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
+ )
- if "success_url" in g.data and g.data["success_url"][:8] != "https://":
- return (
- jsonify({"message": "Success URL must be secure https://..."}),
- 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:
- return (
- jsonify({"message": "Pay link does not exist."}),
- HTTPStatus.NOT_FOUND,
+ raise HTTPException(
+ detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
- if link.wallet != g.wallet.id:
- return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
+ if link.wallet != wallet.wallet.id:
+ raise HTTPException(
+ detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
+ )
- link = await update_pay_link(link_id, **g.data)
+ link = await update_pay_link(**data.dict(), link_id=link_id)
else:
- link = await create_pay_link(wallet_id=g.wallet.id, **g.data)
-
- return (
- jsonify({**link._asdict(), **{"lnurl": link.lnurl}}),
- HTTPStatus.OK if link_id else HTTPStatus.CREATED,
- )
+ link = await create_pay_link(data, wallet_id=wallet.wallet.id)
+ return {**link.dict(), "lnurl": link.lnurl}
-@lnurlp_ext.route("/api/v1/links/", methods=["DELETE"])
-@api_check_wallet_key("invoice")
-async def api_link_delete(link_id):
+@lnurlp_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:
- return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
+ raise HTTPException(
+ detail="Pay link does not exist.", status_code=HTTPStatus.NOT_FOUND
+ )
- if link.wallet != g.wallet.id:
- return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
+ if link.wallet != wallet.wallet.id:
+ raise HTTPException(
+ detail="Not your pay link.", status_code=HTTPStatus.FORBIDDEN
+ )
await delete_pay_link(link_id)
-
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-@lnurlp_ext.route("/api/v1/rate/", methods=["GET"])
+@lnurlp_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 jsonify({"rate": rate}), HTTPStatus.OK
+ return {"rate": rate}
diff --git a/lnbits/extensions/lnurlpayout/README.md b/lnbits/extensions/lnurlpayout/README.md
new file mode 100644
index 00000000..ddf209fe
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/README.md
@@ -0,0 +1,3 @@
+# LNURLPayOut
+
+## Auto-dump a wallets funds to an LNURLpay
diff --git a/lnbits/extensions/lnurlpayout/__init__.py b/lnbits/extensions/lnurlpayout/__init__.py
new file mode 100644
index 00000000..1626e2e5
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/__init__.py
@@ -0,0 +1,24 @@
+import asyncio
+from fastapi import APIRouter
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
+
+db = Database("ext_lnurlpayout")
+
+lnurlpayout_ext: APIRouter = APIRouter(prefix="/lnurlpayout", tags=["lnurlpayout"])
+
+
+def lnurlpayout_renderer():
+ return template_renderer(["lnbits/extensions/lnurlpayout/templates"])
+
+
+from .tasks import wait_for_paid_invoices
+from .views import * # noqa
+from .views_api import * # noqa
+
+
+def lnurlpayout_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/lnurlpayout/config.json.example b/lnbits/extensions/lnurlpayout/config.json.example
new file mode 100644
index 00000000..1e72c0c1
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/config.json.example
@@ -0,0 +1,6 @@
+{
+ "name": "LNURLPayout",
+ "short_description": "Autodump wallet funds to LNURLpay",
+ "icon": "exit_to_app",
+ "contributors": ["arcbtc"]
+}
diff --git a/lnbits/extensions/lnurlpayout/crud.py b/lnbits/extensions/lnurlpayout/crud.py
new file mode 100644
index 00000000..6cbf6c54
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/crud.py
@@ -0,0 +1,62 @@
+from typing import List, Optional, Union
+
+from lnbits.helpers import urlsafe_short_hash
+
+from . import db
+from .models import lnurlpayout, CreateLnurlPayoutData
+
+
+async def create_lnurlpayout(
+ wallet_id: str, admin_key: str, data: CreateLnurlPayoutData
+) -> lnurlpayout:
+ lnurlpayout_id = urlsafe_short_hash()
+ await db.execute(
+ """
+ INSERT INTO lnurlpayout.lnurlpayouts (id, title, wallet, admin_key, lnurlpay, threshold)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (
+ lnurlpayout_id,
+ data.title,
+ wallet_id,
+ admin_key,
+ data.lnurlpay,
+ data.threshold,
+ ),
+ )
+
+ lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
+ assert lnurlpayout, "Newly created lnurlpayout couldn't be retrieved"
+ return lnurlpayout
+
+
+async def get_lnurlpayout(lnurlpayout_id: str) -> Optional[lnurlpayout]:
+ row = await db.fetchone(
+ "SELECT * FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,)
+ )
+ return lnurlpayout(**row) if row else None
+
+
+async def get_lnurlpayout_from_wallet(wallet_id: str) -> Optional[lnurlpayout]:
+ row = await db.fetchone(
+ "SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet = ?", (wallet_id,)
+ )
+ return lnurlpayout(**row) if row else None
+
+
+async def get_lnurlpayouts(wallet_ids: Union[str, List[str]]) -> List[lnurlpayout]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet IN ({q})", (*wallet_ids,)
+ )
+
+ return [lnurlpayout(**row) if row else None for row in rows]
+
+
+async def delete_lnurlpayout(lnurlpayout_id: str) -> None:
+ await db.execute(
+ "DELETE FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,)
+ )
diff --git a/lnbits/extensions/lnurlpayout/migrations.py b/lnbits/extensions/lnurlpayout/migrations.py
new file mode 100644
index 00000000..6af04791
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/migrations.py
@@ -0,0 +1,16 @@
+async def m001_initial(db):
+ """
+ Initial lnurlpayouts table.
+ """
+ await db.execute(
+ """
+ CREATE TABLE lnurlpayout.lnurlpayouts (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ wallet TEXT NOT NULL,
+ admin_key TEXT NOT NULL,
+ lnurlpay TEXT NOT NULL,
+ threshold INT NOT NULL
+ );
+ """
+ )
diff --git a/lnbits/extensions/lnurlpayout/models.py b/lnbits/extensions/lnurlpayout/models.py
new file mode 100644
index 00000000..fc8be575
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/models.py
@@ -0,0 +1,18 @@
+from sqlite3 import Row
+
+from pydantic import BaseModel
+
+
+class CreateLnurlPayoutData(BaseModel):
+ title: str
+ lnurlpay: str
+ threshold: int
+
+
+class lnurlpayout(BaseModel):
+ id: str
+ title: str
+ wallet: str
+ admin_key: str
+ lnurlpay: str
+ threshold: int
diff --git a/lnbits/extensions/lnurlpayout/tasks.py b/lnbits/extensions/lnurlpayout/tasks.py
new file mode 100644
index 00000000..7f2a8324
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/tasks.py
@@ -0,0 +1,89 @@
+import asyncio
+from http import HTTPStatus
+
+import httpx
+from starlette.exceptions import HTTPException
+
+from lnbits.core import db as core_db
+from lnbits.core.crud import get_wallet
+from lnbits.core.models import Payment
+from lnbits.core.services import pay_invoice
+from lnbits.core.views.api import api_payments_decode
+from lnbits.tasks import register_invoice_listener
+
+from .crud import get_lnurlpayout_from_wallet
+
+
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
+
+ while True:
+ payment = await invoice_queue.get()
+ await on_invoice_paid(payment)
+
+
+async def on_invoice_paid(payment: Payment) -> None:
+ try:
+ # Check its got a payout associated with it
+ lnurlpayout_link = await get_lnurlpayout_from_wallet(payment.wallet_id)
+ print("LNURLpayout", lnurlpayout_link)
+ if lnurlpayout_link:
+
+ # Check the wallet balance is more than the threshold
+
+ wallet = await get_wallet(lnurlpayout_link.wallet)
+ threshold = lnurlpayout_link.threshold + (lnurlpayout_link.threshold * 0.02)
+
+ if wallet.balance < threshold:
+ return
+ # Get the invoice from the LNURL to pay
+ async with httpx.AsyncClient() as client:
+ try:
+ url = await api_payments_decode({"data": lnurlpayout_link.lnurlpay})
+ if str(url["domain"])[0:4] != "http":
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="LNURL broken"
+ )
+
+ try:
+ r = await client.get(str(url["domain"]), timeout=40)
+ res = r.json()
+ try:
+ r = await client.get(
+ res["callback"]
+ + "?amount="
+ + str(
+ int((wallet.balance - wallet.balance * 0.02) * 1000)
+ ),
+ timeout=40,
+ )
+ res = r.json()
+
+ if hasattr(res, "status") and res["status"] == "ERROR":
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail=res["reason"],
+ )
+ try:
+ await pay_invoice(
+ wallet_id=payment.wallet_id,
+ payment_request=res["pr"],
+ extra={"tag": "lnurlpayout"},
+ )
+ return
+ except:
+ pass
+
+ except Exception as e:
+ print("ERROR", str(e))
+ return
+ except (httpx.ConnectError, httpx.RequestError):
+ return
+ except Exception:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Failed to save LNURLPayout",
+ )
+ except:
+ return
diff --git a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html
new file mode 100644
index 00000000..7febea44
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/_api_docs.html
@@ -0,0 +1,118 @@
+
+
+
+
+ GET
+ /lnurlpayout/api/v1/lnurlpayouts
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ [<lnurlpayout_object>, ...]
+ Curl example
+ curl -X GET {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -H
+ "X-Api-Key: <invoice_key>"
+
+
+
+
+
+
+
+ POST
+ /lnurlpayout/api/v1/lnurlpayouts
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+ {"name": <string>, "currency": <string*ie USD*>}
+
+ Returns 201 CREATED (application/json)
+
+ {"currency": <string>, "id": <string>, "name":
+ <string>, "wallet": <string>}
+ Curl example
+ curl -X POST {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -d
+ '{"name": <string>, "currency": <string>}' -H
+ "Content-type: application/json" -H "X-Api-Key: <admin_key>"
+
+
+
+
+
+
+
+
+ DELETE
+ /lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Returns 204 NO CONTENT
+
+ Curl example
+ curl -X DELETE {{ request.base_url
+ }}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H
+ "X-Api-Key: <admin_key>"
+
+
+
+
+
+
+
+ GET
+ /lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ [<lnurlpayout_object>, ...]
+ Curl example
+ curl -X GET {{ request.base_url
+ }}lnurlpayout/api/v1/lnurlpayouts/<lnurlpayout_id> -H
+ "X-Api-Key: <invoice_key>"
+
+
+
+
+
diff --git a/lnbits/extensions/lnurlpayout/templates/lnurlpayout/index.html b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/index.html
new file mode 100644
index 00000000..ad43add1
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/templates/lnurlpayout/index.html
@@ -0,0 +1,268 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+ New LNURLPayout
+
+
+
+
+
+
+
+ LNURLPayout
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+ {{ col.label}}
+
+
+
+
+
+
+
+
+ Click to copy LNURL {{
+ col.value.substring(0, 40) }}...
+
+ {{ col.value }} Sats
+
+ {{ col.value.substring(0, 40) }}
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} LNURLPayout extension
+
+
+
+
+
+ {% include "lnurlpayout/_api_docs.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create LNURLPayout
+ Cancel
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/lnurlpayout/views.py b/lnbits/extensions/lnurlpayout/views.py
new file mode 100644
index 00000000..454a3332
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/views.py
@@ -0,0 +1,22 @@
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.params import Depends
+from fastapi.templating import Jinja2Templates
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse
+
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import lnurlpayout_ext, lnurlpayout_renderer
+from .crud import get_lnurlpayout
+
+templates = Jinja2Templates(directory="templates")
+
+
+@lnurlpayout_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return lnurlpayout_renderer().TemplateResponse(
+ "lnurlpayout/index.html", {"request": request, "user": user.dict()}
+ )
diff --git a/lnbits/extensions/lnurlpayout/views_api.py b/lnbits/extensions/lnurlpayout/views_api.py
new file mode 100644
index 00000000..67562909
--- /dev/null
+++ b/lnbits/extensions/lnurlpayout/views_api.py
@@ -0,0 +1,118 @@
+from http import HTTPStatus
+
+from fastapi import Query
+from fastapi.params import Depends
+from starlette.exceptions import HTTPException
+
+from lnbits.core.crud import get_payments, get_user
+from lnbits.core.models import Payment
+from lnbits.core.services import create_invoice
+from lnbits.core.views.api import api_payment, api_payments_decode
+from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
+
+from . import lnurlpayout_ext
+from .crud import (
+ create_lnurlpayout,
+ delete_lnurlpayout,
+ get_lnurlpayout,
+ get_lnurlpayout_from_wallet,
+ get_lnurlpayouts,
+)
+from .models import CreateLnurlPayoutData, lnurlpayout
+from .tasks import on_invoice_paid
+
+
+@lnurlpayout_ext.get("/api/v1/lnurlpayouts", status_code=HTTPStatus.OK)
+async def api_lnurlpayouts(
+ all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ wallet_ids = [wallet.wallet.id]
+ if all_wallets:
+ wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
+
+ return [lnurlpayout.dict() for lnurlpayout in await get_lnurlpayouts(wallet_ids)]
+
+
+@lnurlpayout_ext.post("/api/v1/lnurlpayouts", status_code=HTTPStatus.CREATED)
+async def api_lnurlpayout_create(
+ data: CreateLnurlPayoutData, wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ if await get_lnurlpayout_from_wallet(wallet.wallet.id):
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Wallet already has lnurlpayout set",
+ )
+ return
+ url = await api_payments_decode({"data": data.lnurlpay})
+ if "domain" not in url:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="LNURL could not be decoded"
+ )
+ return
+ if str(url["domain"])[0:4] != "http":
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not valid LNURL")
+ return
+ lnurlpayout = await create_lnurlpayout(
+ wallet_id=wallet.wallet.id, admin_key=wallet.wallet.adminkey, data=data
+ )
+ if not lnurlpayout:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout"
+ )
+ return
+ return lnurlpayout.dict()
+
+
+@lnurlpayout_ext.delete("/api/v1/lnurlpayouts/{lnurlpayout_id}")
+async def api_lnurlpayout_delete(
+ lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
+):
+ lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
+
+ if not lnurlpayout:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="lnurlpayout does not exist."
+ )
+
+ if lnurlpayout.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your lnurlpayout."
+ )
+
+ await delete_lnurlpayout(lnurlpayout_id)
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
+
+
+@lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK)
+async def api_lnurlpayout_check(
+ lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
+ ## THIS
+ mock_payment = Payment(
+ checking_id="mock",
+ pending=False,
+ amount=1,
+ fee=1,
+ time=0000,
+ bolt11="mock",
+ preimage="mock",
+ payment_hash="mock",
+ wallet_id=lnurlpayout.wallet,
+ )
+ ## INSTEAD OF THIS
+ # payments = await get_payments(
+ # wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
+ # )
+
+ result = await on_invoice_paid(mock_payment)
+ return
+
+
+# get payouts func
+# lnurlpayouts = await get_lnurlpayouts(wallet_ids)
+# for lnurlpayout in lnurlpayouts:
+# payments = await get_payments(
+# wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
+# )
+# await on_invoice_paid(payments[0])
diff --git a/lnbits/extensions/ngrok/README.md b/lnbits/extensions/ngrok/README.md
new file mode 100644
index 00000000..626c788f
--- /dev/null
+++ b/lnbits/extensions/ngrok/README.md
@@ -0,0 +1,24 @@
+Ngrok
+Serve lnbits over https for free using ngrok
+
+
+
+How it works
+
+When enabled, ngrok creates a tunnel to ngrok.io with https support and tells you the https web address where you can access your lnbits instance. If you are not the first user to enable it, it doesn't create a new one, it just tells you the existing one. Useful for creating/managing/using lnurls, which must be served either via https or via tor. Note that if you restart your device, your device will generate a new url. If anyone is using your old one for wallets, lnurls, etc., whatever they are doing will stop working.
+
+Installation
+
+Check the Extensions page on your instance of lnbits. If you have copy of lnbits with ngrok as one of the built in extensions, click Enable -- that's the only thing you need to do to install it.
+
+If your copy of lnbits does not have ngrok as one of the built in extensions, stop lnbits, create go into your lnbits folder, and run this command: ./venv/bin/pip install pyngrok. Then go into the lnbits subdirectory and the extensions subdirectory within that. (So lnbits > lnbits > extensions.) Create a new subdirectory in there called freetunnel, download this repository as a zip file, and unzip it in the freetunnel directory. If your unzipper creates a new "freetunnel" subdirectory, take everything out of there and put it in the freetunnel directory you created. Then go back to the top level lnbits directory and run these commands:
+
+```
+./venv/bin/quart assets
+./venv/bin/quart migrate
+./venv/bin/hypercorn -k trio --bind 0.0.0.0:5000 'lnbits.app:create_app()'
+```
+
+Optional: set up an ngrok.com account
+
+The default setup makes a tunnel on a random subdomain, and the session times out after 24h or a certain bandwith limit. You can set up an account at ngrok.com; a free plan removes the timeout, and a paid plan lets you choose a custom subdomain (or even use your own domain). For this, get an auth token from ngrok.com, and then set it up as `NGROK_AUTHTOKEN` environment variable on your `.env` file e.g., if your auth token is xxxx, add a line NGROK_AUTHTOKEN=xxxx.
diff --git a/lnbits/extensions/ngrok/__init__.py b/lnbits/extensions/ngrok/__init__.py
new file mode 100644
index 00000000..0ef256d1
--- /dev/null
+++ b/lnbits/extensions/ngrok/__init__.py
@@ -0,0 +1,15 @@
+from fastapi import APIRouter
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+
+db = Database("ext_ngrok")
+
+ngrok_ext: APIRouter = APIRouter(prefix="/ngrok", tags=["ngrok"])
+
+
+def ngrok_renderer():
+ return template_renderer(["lnbits/extensions/ngrok/templates"])
+
+
+from .views import *
diff --git a/lnbits/extensions/ngrok/config.json.example b/lnbits/extensions/ngrok/config.json.example
new file mode 100644
index 00000000..58e9ff8e
--- /dev/null
+++ b/lnbits/extensions/ngrok/config.json.example
@@ -0,0 +1,6 @@
+{
+ "name": "Ngrok",
+ "short_description": "Serve lnbits over https for free using ngrok",
+ "icon": "trip_origin",
+ "contributors": ["supertestnet"]
+}
diff --git a/lnbits/extensions/example/migrations.py b/lnbits/extensions/ngrok/migrations.py
similarity index 58%
rename from lnbits/extensions/example/migrations.py
rename to lnbits/extensions/ngrok/migrations.py
index aca4a27e..f9b8b37d 100644
--- a/lnbits/extensions/example/migrations.py
+++ b/lnbits/extensions/ngrok/migrations.py
@@ -2,10 +2,10 @@
# await db.execute(
# """
-# CREATE TABLE IF NOT EXISTS example (
+# CREATE TABLE example.example (
# id TEXT PRIMARY KEY,
# wallet TEXT NOT NULL,
-# time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+# time TIMESTAMP NOT NULL DEFAULT """ + db.timestamp_now + """
# );
# """
# )
diff --git a/lnbits/extensions/ngrok/templates/ngrok/index.html b/lnbits/extensions/ngrok/templates/ngrok/index.html
new file mode 100644
index 00000000..3af4fa44
--- /dev/null
+++ b/lnbits/extensions/ngrok/templates/ngrok/index.html
@@ -0,0 +1,53 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+
+
+ Access this lnbits instance at the following url
+
+
+
+
+
+
+
+
+
+
+ Ngrok extension
+
+
+
+
+
+ Note that if you restart your device, your device will generate a
+ new url. If anyone is using your old one for wallets, lnurls,
+ etc., whatever they are doing will stop working.
+
+ Created by
+ Supertestnet.
+
+
+
+
+
+
+
+{% endblock %}{% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/ngrok/views.py b/lnbits/extensions/ngrok/views.py
new file mode 100644
index 00000000..1a34fd51
--- /dev/null
+++ b/lnbits/extensions/ngrok/views.py
@@ -0,0 +1,40 @@
+from os import getenv
+
+from fastapi import Request
+from fastapi.params import Depends
+from fastapi.templating import Jinja2Templates
+from pyngrok import conf, ngrok
+
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import ngrok_ext, ngrok_renderer
+
+templates = Jinja2Templates(directory="templates")
+
+
+def log_event_callback(log):
+ string = str(log)
+ string2 = string[string.find('url="https') : string.find('url="https') + 80]
+ if string2:
+ string3 = string2
+ string4 = string3[4:]
+ global string5
+ string5 = string4.replace('"', "")
+
+
+conf.get_default().log_event_callback = log_event_callback
+
+ngrok_authtoken = getenv("NGROK_AUTHTOKEN")
+if ngrok_authtoken is not None:
+ ngrok.set_auth_token(ngrok_authtoken)
+
+port = getenv("PORT")
+ngrok_tunnel = ngrok.connect(port)
+
+
+@ngrok_ext.get("/")
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return ngrok_renderer().TemplateResponse(
+ "ngrok/index.html", {"request": request, "ngrok": string5, "user": user.dict()}
+ )
diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py
index 1f9dd123..a601c1b8 100644
--- a/lnbits/extensions/offlineshop/__init__.py
+++ b/lnbits/extensions/offlineshop/__init__.py
@@ -1,14 +1,26 @@
-from quart import Blueprint
+from fastapi import APIRouter
+from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
+from lnbits.helpers import template_renderer
db = Database("ext_offlineshop")
-offlineshop_ext: Blueprint = Blueprint(
- "offlineshop", __name__, static_folder="static", template_folder="templates"
-)
+offlineshop_static_files = [
+ {
+ "path": "/offlineshop/static",
+ "app": StaticFiles(directory="lnbits/extensions/offlineshop/static"),
+ "name": "offlineshop_static",
+ }
+]
+
+offlineshop_ext: APIRouter = APIRouter(prefix="/offlineshop", tags=["Offlineshop"])
+
+
+def offlineshop_renderer():
+ return template_renderer(["lnbits/extensions/offlineshop/templates"])
-from .views_api import * # noqa
-from .views import * # noqa
from .lnurl import * # noqa
+from .views import * # noqa
+from .views_api import * # noqa
diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py
index e6bd0d6c..0ecb3d52 100644
--- a/lnbits/extensions/offlineshop/crud.py
+++ b/lnbits/extensions/offlineshop/crud.py
@@ -1,28 +1,38 @@
from typing import List, Optional
+from lnbits.db import SQLITE
from . import db
from .wordlists import animals
from .models import Shop, Item
async def create_shop(*, wallet_id: str) -> int:
- result = await db.execute(
- """
- INSERT INTO shops (wallet, wordlist, method)
+ returning = "" if db.type == SQLITE else "RETURNING ID"
+ method = db.execute if db.type == SQLITE else db.fetchone
+
+ result = await (method)(
+ f"""
+ INSERT INTO offlineshop.shops (wallet, wordlist, method)
VALUES (?, ?, 'wordlist')
+ {returning}
""",
(wallet_id, "\n".join(animals)),
)
- return result._result_proxy.lastrowid
+ if db.type == SQLITE:
+ return result._result_proxy.lastrowid
+ else:
+ return result[0]
async def get_shop(id: int) -> Optional[Shop]:
- row = await db.fetchone("SELECT * FROM shops WHERE id = ?", (id,))
+ row = await db.fetchone("SELECT * FROM offlineshop.shops WHERE id = ?", (id,))
return Shop(**dict(row)) if row else None
async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
- row = await db.fetchone("SELECT * FROM shops WHERE wallet = ?", (wallet,))
+ row = await db.fetchone(
+ "SELECT * FROM offlineshop.shops WHERE wallet = ?", (wallet,)
+ )
if not row:
# create on the fly
@@ -34,23 +44,18 @@ async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]:
async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]:
await db.execute(
- "UPDATE shops SET method = ?, wordlist = ? WHERE id = ?",
+ "UPDATE offlineshop.shops SET method = ?, wordlist = ? WHERE id = ?",
(method, wordlist, shop),
)
return await get_shop(shop)
async def add_item(
- shop: int,
- name: str,
- description: str,
- image: Optional[str],
- price: int,
- unit: str,
+ shop: int, name: str, description: str, image: Optional[str], price: int, unit: str
) -> int:
result = await db.execute(
"""
- INSERT INTO items (shop, name, description, image, price, unit)
+ INSERT INTO offlineshop.items (shop, name, description, image, price, unit)
VALUES (?, ?, ?, ?, ?, ?)
""",
(shop, name, description, image, price, unit),
@@ -69,7 +74,7 @@ async def update_item(
) -> int:
await db.execute(
"""
- UPDATE items SET
+ UPDATE offlineshop.items SET
name = ?,
description = ?,
image = ?,
@@ -83,19 +88,21 @@ async def update_item(
async def get_item(id: int) -> Optional[Item]:
- row = await db.fetchone("SELECT * FROM items WHERE id = ? LIMIT 1", (id,))
+ row = await db.fetchone(
+ "SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,)
+ )
return Item(**dict(row)) if row else None
async def get_items(shop: int) -> List[Item]:
- rows = await db.fetchall("SELECT * FROM items WHERE shop = ?", (shop,))
+ rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,))
return [Item(**dict(row)) for row in rows]
async def delete_item_from_shop(shop: int, item_id: int):
await db.execute(
"""
- DELETE FROM items WHERE shop = ? AND id = ?
+ DELETE FROM offlineshop.items WHERE shop = ? AND id = ?
""",
(shop, item_id),
)
diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py
index 6b56cf55..db2c19cc 100644
--- a/lnbits/extensions/offlineshop/helpers.py
+++ b/lnbits/extensions/offlineshop/helpers.py
@@ -8,8 +8,8 @@ def hotp(key, counter, digits=6, digest="sha1"):
key = base64.b32decode(key.upper() + "=" * ((8 - len(key)) % 8))
counter = struct.pack(">Q", counter)
mac = hmac.new(key, counter, digest).digest()
- offset = mac[-1] & 0x0F
- binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF
+ offset = mac[-1] & 0x0f
+ binary = struct.unpack(">L", mac[offset : offset + 4])[0] & 0x7fffffff
return str(binary)[-digits:].zfill(digits)
diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py
index d99e4cea..0bf779e4 100644
--- a/lnbits/extensions/offlineshop/lnurl.py
+++ b/lnbits/extensions/offlineshop/lnurl.py
@@ -1,22 +1,29 @@
import hashlib
-from quart import jsonify, url_for, request
-from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore
+
+from fastapi.params import Query
+from lnurl import ( # type: ignore
+ LnurlErrorResponse,
+ LnurlPayActionResponse,
+ LnurlPayResponse,
+)
+from starlette.requests import Request
from lnbits.core.services import create_invoice
+from lnbits.extensions.offlineshop.models import Item
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from . import offlineshop_ext
-from .crud import get_shop, get_item
+from .crud import get_item, get_shop
-@offlineshop_ext.route("/lnurl/", methods=["GET"])
-async def lnurl_response(item_id):
- item = await get_item(item_id)
+@offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response")
+async def lnurl_response(req: Request, item_id: int = Query(...)):
+ item = await get_item(item_id) # type: Item
if not item:
- return jsonify({"status": "ERROR", "reason": "Item not found."})
+ return {"status": "ERROR", "reason": "Item not found."}
if not item.enabled:
- return jsonify({"status": "ERROR", "reason": "Item disabled."})
+ return {"status": "ERROR", "reason": "Item disabled."}
price_msat = (
await fiat_amount_as_satoshis(item.price, item.unit)
@@ -25,20 +32,20 @@ async def lnurl_response(item_id):
) * 1000
resp = LnurlPayResponse(
- callback=url_for("offlineshop.lnurl_callback", item_id=item.id, _external=True),
+ callback=req.url_for("offlineshop.lnurl_callback", item_id=item.id),
min_sendable=price_msat,
max_sendable=price_msat,
metadata=await item.lnurlpay_metadata(),
)
- return jsonify(resp.dict())
+ return resp.dict()
-@offlineshop_ext.route("/lnurl/cb/", methods=["GET"])
-async def lnurl_callback(item_id):
- item = await get_item(item_id)
+@offlineshop_ext.get("/lnurl/cb/{item_id}", name="offlineshop.lnurl_callback")
+async def lnurl_callback(request: Request, item_id: int):
+ item = await get_item(item_id) # type: Item
if not item:
- return jsonify({"status": "ERROR", "reason": "Couldn't find item."})
+ return {"status": "ERROR", "reason": "Couldn't find item."}
if item.unit == "sat":
min = item.price * 1000
@@ -49,19 +56,15 @@ async def lnurl_callback(item_id):
min = price * 995
max = price * 1010
- amount_received = int(request.args.get("amount") or 0)
+ amount_received = int(request.query_params.get("amount") or 0)
if amount_received < min:
- return jsonify(
- LnurlErrorResponse(
- reason=f"Amount {amount_received} is smaller than minimum {min}."
- ).dict()
- )
+ return LnurlErrorResponse(
+ reason=f"Amount {amount_received} is smaller than minimum {min}."
+ ).dict()
elif amount_received > max:
- return jsonify(
- LnurlErrorResponse(
- reason=f"Amount {amount_received} is greater than maximum {max}."
- ).dict()
- )
+ return LnurlErrorResponse(
+ reason=f"Amount {amount_received} is greater than maximum {max}."
+ ).dict()
shop = await get_shop(item.shop)
@@ -76,12 +79,14 @@ async def lnurl_callback(item_id):
extra={"tag": "offlineshop", "item": item.id},
)
except Exception as exc:
- return jsonify(LnurlErrorResponse(reason=exc.message).dict())
+ return LnurlErrorResponse(reason=exc.message).dict()
resp = LnurlPayActionResponse(
pr=payment_request,
- success_action=item.success_action(shop, payment_hash) if shop.method else None,
+ success_action=item.success_action(shop, payment_hash, request)
+ if shop.method
+ else None,
routes=[],
)
- return jsonify(resp.dict())
+ return resp.dict()
diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py
index 8e8a4877..f7c2dfec 100644
--- a/lnbits/extensions/offlineshop/migrations.py
+++ b/lnbits/extensions/offlineshop/migrations.py
@@ -3,9 +3,9 @@ async def m001_initial(db):
Initial offlineshop tables.
"""
await db.execute(
- """
- CREATE TABLE shops (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
+ f"""
+ CREATE TABLE offlineshop.shops (
+ id {db.serial_primary_key},
wallet TEXT NOT NULL,
method TEXT NOT NULL,
wordlist TEXT
@@ -14,10 +14,10 @@ async def m001_initial(db):
)
await db.execute(
- """
- CREATE TABLE items (
- shop INTEGER NOT NULL REFERENCES shop (id),
- id INTEGER PRIMARY KEY AUTOINCREMENT,
+ f"""
+ CREATE TABLE offlineshop.items (
+ shop INTEGER NOT NULL REFERENCES {db.references_schema}shops (id),
+ id {db.serial_primary_key},
name TEXT NOT NULL,
description TEXT NOT NULL,
image TEXT, -- image/png;base64,...
diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py
index eb767cdf..06225351 100644
--- a/lnbits/extensions/offlineshop/models.py
+++ b/lnbits/extensions/offlineshop/models.py
@@ -2,18 +2,20 @@ import json
import base64
import hashlib
from collections import OrderedDict
-from quart import url_for
-from typing import NamedTuple, Optional, List, Dict
+
+from typing import Optional, List, Dict
from lnurl import encode as lnurl_encode # type: ignore
from lnurl.types import LnurlPayMetadata # type: ignore
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
-
+from pydantic import BaseModel
+from starlette.requests import Request
from .helpers import totp
shop_counters: Dict = {}
-class ShopCounter(object):
+class ShopCounter:
+ wordlist: List[str]
fulfilled_payments: OrderedDict
counter: int
@@ -54,7 +56,7 @@ class ShopCounter(object):
return word
-class Shop(NamedTuple):
+class Shop(BaseModel):
id: int
wallet: str
method: str
@@ -64,7 +66,7 @@ class Shop(NamedTuple):
def otp_key(self) -> str:
return base64.b32encode(
hashlib.sha256(
- ("otpkey" + str(self.id) + self.wallet).encode("ascii"),
+ ("otpkey" + str(self.id) + self.wallet).encode("ascii")
).digest()
).decode("ascii")
@@ -77,25 +79,24 @@ class Shop(NamedTuple):
return ""
-class Item(NamedTuple):
+class Item(BaseModel):
shop: int
id: int
name: str
description: str
- image: str
+ image: Optional[str]
enabled: bool
price: int
unit: str
- @property
- def lnurl(self) -> str:
- return lnurl_encode(
- url_for("offlineshop.lnurl_response", item_id=self.id, _external=True)
- )
+ def lnurl(self, req: Request) -> str:
+ return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id))
- def values(self):
- values = self._asdict()
- values["lnurl"] = self.lnurl
+ def values(self, req: Request):
+ values = self.dict()
+ values["lnurl"] = lnurl_encode(
+ req.url_for("offlineshop.lnurl_response", item_id=self.id)
+ )
return values
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
@@ -107,14 +108,12 @@ class Item(NamedTuple):
return LnurlPayMetadata(json.dumps(metadata))
def success_action(
- self, shop: Shop, payment_hash: str
+ self, shop: Shop, payment_hash: str, req: Request
) -> Optional[LnurlPaySuccessAction]:
if not shop.wordlist:
return None
return UrlAction(
- url=url_for(
- "offlineshop.confirmation_code", p=payment_hash, _external=True
- ),
+ url=req.url_for("offlineshop.confirmation_code", p=payment_hash),
description="Open to get the confirmation code for your purchase.",
)
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html
index 1e3bf051..ac655697 100644
--- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html
+++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html
@@ -62,9 +62,9 @@
Returns 201 OK
Curl example
curl -X GET {{ request.url_root
+ >curl -X GET {{ request.base_url
}}/offlineshop/api/v1/offlineshop/items -H "Content-Type:
- application/json" -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -d
+ application/json" -H "X-Api-Key: {{ user.wallets[0].inkey }}" -d
'{"name": <string>, "description": <string>, "image":
<data-uri string>, "price": <integer>, "unit": <"sat"
or "USD">}'
@@ -96,8 +96,8 @@
>
Curl example
curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}/offlineshop/api/v1/offlineshop -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -117,12 +117,12 @@
Returns 200 OK
Curl example
curl -X GET {{ request.url_root
+ >curl -X GET {{ request.base_url
}}/offlineshop/api/v1/offlineshop/items/<item_id> -H
"Content-Type: application/json" -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}" -d '{"name": <string>,
- "description": <string>, "image": <data-uri string>,
- "price": <integer>, "unit": <"sat" or "USD">}'
+ user.wallets[0].inkey }}" -d '{"name": <string>, "description":
+ <string>, "image": <data-uri string>, "price":
+ <integer>, "unit": <"sat" or "USD">}'
@@ -137,9 +137,9 @@
Returns 200 OK
Curl example
curl -X GET {{ request.url_root
+ >curl -X GET {{ request.base_url
}}/offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key:
- {{ g.user.wallets[0].inkey }}"
+ {{ user.wallets[0].inkey }}"
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html
index 1a388e92..01b8e8da 100644
--- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html
+++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html
@@ -9,7 +9,7 @@
Items
- Add new item
@@ -111,7 +111,7 @@
Print QR Codes
@@ -123,7 +123,7 @@
@@ -151,7 +151,7 @@
>
@@ -180,7 +180,7 @@
>
@@ -199,7 +199,7 @@
@@ -213,7 +213,9 @@
- LNbits OfflineShop extension
+
+ {{SITE_TITLE}} OfflineShop extension
+
@@ -308,7 +310,7 @@
@@ -329,5 +331,5 @@
{% endblock %} {% block scripts %} {{ window_vars(user) }}
-
+
{% endblock %}
diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py
index 33702f6b..e1d3a66e 100644
--- a/lnbits/extensions/offlineshop/views.py
+++ b/lnbits/extensions/offlineshop/views.py
@@ -1,60 +1,76 @@
import time
from datetime import datetime
-from quart import g, render_template, request
from http import HTTPStatus
+from typing import List
-from lnbits.decorators import check_user_exists, validate_uuids
-from lnbits.core.models import Payment
+from fastapi.params import Depends, Query
+from starlette.responses import HTMLResponse
+
+from lnbits.decorators import check_user_exists
+from lnbits.core.models import Payment, User
from lnbits.core.crud import get_standalone_payment
+from lnbits.core.views.api import api_payment
-from . import offlineshop_ext
+from . import offlineshop_ext, offlineshop_renderer
+from .models import Item
from .crud import get_item, get_shop
+from fastapi import Request, HTTPException
-@offlineshop_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("offlineshop/index.html", user=g.user)
+@offlineshop_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return offlineshop_renderer().TemplateResponse(
+ "offlineshop/index.html", {"request": request, "user": user.dict()}
+ )
-@offlineshop_ext.route("/print")
-async def print_qr_codes():
+@offlineshop_ext.get("/print", response_class=HTMLResponse)
+async def print_qr_codes(request: Request, items: List[int] = None):
items = []
- for item_id in request.args.get("items").split(","):
- item = await get_item(item_id)
+ for item_id in request.query_params.get("items").split(","):
+ item = await get_item(item_id) # type: Item
if item:
items.append(
{
- "lnurl": item.lnurl,
+ "lnurl": item.lnurl(request),
"name": item.name,
"price": f"{item.price} {item.unit}",
}
)
- return await render_template("offlineshop/print.html", items=items)
+ return offlineshop_renderer().TemplateResponse(
+ "offlineshop/print.html", {"request": request, "items": items}
+ )
-@offlineshop_ext.route("/confirmation")
-async def confirmation_code():
+@offlineshop_ext.get(
+ "/confirmation/{p}",
+ name="offlineshop.confirmation_code",
+ response_class=HTMLResponse,
+)
+async def confirmation_code(p: str = Query(...)):
style = ""
- payment_hash = request.args.get("p")
+ payment_hash = p
+ await api_payment(payment_hash)
payment: Payment = await get_standalone_payment(payment_hash)
if not payment:
- return (
- f"Couldn't find the payment {payment_hash}." + style,
- HTTPStatus.NOT_FOUND,
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail=f"Couldn't find the payment {payment_hash}." + style,
)
if payment.pending:
- return (
- f"Payment {payment_hash} wasn't received yet. Please try again in a minute."
+ raise HTTPException(
+ status_code=HTTPStatus.PAYMENT_REQUIRED,
+ detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute."
+ style,
- HTTPStatus.PAYMENT_REQUIRED,
)
if payment.time + 60 * 15 < time.time():
- return "too much time has passed." + style
+ raise HTTPException(
+ status_code=HTTPStatus.REQUEST_TIMEOUT,
+ detail="Too much time has passed." + style,
+ )
item = await get_item(payment.extra.get("item"))
shop = await get_shop(item.shop)
diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py
index ee3631a7..5ced4351 100644
--- a/lnbits/extensions/offlineshop/views_api.py
+++ b/lnbits/extensions/offlineshop/views_api.py
@@ -1,128 +1,110 @@
-from quart import g, jsonify
from http import HTTPStatus
-from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
+from typing import Optional
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
+from fastapi.params import Depends
+from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
+from pydantic.main import BaseModel
+from starlette.exceptions import HTTPException
+from starlette.requests import Request
+from starlette.responses import HTMLResponse # type: ignore
+
+from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.utils.exchange_rates import currencies
from . import offlineshop_ext
from .crud import (
+ add_item,
+ delete_item_from_shop,
+ get_items,
get_or_create_shop_by_wallet,
set_method,
- add_item,
update_item,
- get_items,
- delete_item_from_shop,
)
from .models import ShopCounter
-@offlineshop_ext.route("/api/v1/currencies", methods=["GET"])
+@offlineshop_ext.get("/api/v1/currencies")
async def api_list_currencies_available():
- return jsonify(list(currencies.keys()))
+ return list(currencies.keys())
-@offlineshop_ext.route("/api/v1/offlineshop", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_shop_from_wallet():
- shop = await get_or_create_shop_by_wallet(g.wallet.id)
+@offlineshop_ext.get("/api/v1/offlineshop")
+async def api_shop_from_wallet(
+ r: Request, wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
items = await get_items(shop.id)
try:
- return (
- jsonify(
- {
- **shop._asdict(),
- **{
- "otp_key": shop.otp_key,
- "items": [item.values() for item in items],
- },
- }
- ),
- HTTPStatus.OK,
- )
+ return {
+ **shop.dict(),
+ **{"otp_key": shop.otp_key, "items": [item.values(r) for item in items]},
+ }
except LnurlInvalidUrl:
- return (
- jsonify(
- {
- "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
- }
- ),
- HTTPStatus.UPGRADE_REQUIRED,
+ raise HTTPException(
+ status_code=HTTPStatus.UPGRADE_REQUIRED,
+ detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
)
-@offlineshop_ext.route("/api/v1/offlineshop/items", methods=["POST"])
-@offlineshop_ext.route("/api/v1/offlineshop/items/", methods=["PUT"])
-@api_check_wallet_key("invoice")
-@api_validate_post_request(
- schema={
- "name": {"type": "string", "empty": False, "required": True},
- "description": {"type": "string", "empty": False, "required": True},
- "image": {"type": "string", "required": False, "nullable": True},
- "price": {"type": "number", "required": True},
- "unit": {"type": "string", "required": True},
- }
-)
-async def api_add_or_update_item(item_id=None):
- shop = await get_or_create_shop_by_wallet(g.wallet.id)
+class CreateItemsData(BaseModel):
+ name: str
+ description: str
+ image: Optional[str]
+ price: int
+ unit: str
+
+
+@offlineshop_ext.post("/api/v1/offlineshop/items")
+@offlineshop_ext.put("/api/v1/offlineshop/items/{item_id}")
+async def api_add_or_update_item(
+ data: CreateItemsData, item_id=None, wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
if item_id == None:
await add_item(
- shop.id,
- g.data["name"],
- g.data["description"],
- g.data.get("image"),
- g.data["price"],
- g.data["unit"],
+ shop.id, data.name, data.description, data.image, data.price, data.unit
)
- return "", HTTPStatus.CREATED
+ return HTMLResponse(status_code=HTTPStatus.CREATED)
else:
await update_item(
shop.id,
item_id,
- g.data["name"],
- g.data["description"],
- g.data.get("image"),
- g.data["price"],
- g.data["unit"],
+ data.name,
+ data.description,
+ data.image,
+ data.price,
+ data.unit,
)
- return "", HTTPStatus.OK
-@offlineshop_ext.route("/api/v1/offlineshop/items/", methods=["DELETE"])
-@api_check_wallet_key("invoice")
-async def api_delete_item(item_id):
- shop = await get_or_create_shop_by_wallet(g.wallet.id)
+@offlineshop_ext.delete("/api/v1/offlineshop/items/{item_id}")
+async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)):
+ shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
await delete_item_from_shop(shop.id, item_id)
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-@offlineshop_ext.route("/api/v1/offlineshop/method", methods=["PUT"])
-@api_check_wallet_key("invoice")
-@api_validate_post_request(
- schema={
- "method": {"type": "string", "required": True, "nullable": False},
- "wordlist": {
- "type": "string",
- "empty": True,
- "nullable": True,
- "required": False,
- },
- }
-)
-async def api_set_method():
- method = g.data["method"]
+class CreateMethodData(BaseModel):
+ method: str
+ wordlist: Optional[str]
- wordlist = g.data["wordlist"].split("\n") if g.data["wordlist"] else None
+
+@offlineshop_ext.put("/api/v1/offlineshop/method")
+async def api_set_method(
+ data: CreateMethodData, wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ method = data.method
+
+ wordlist = data.wordlist.split("\n") if data.wordlist else None
wordlist = [word.strip() for word in wordlist if word.strip()]
- shop = await get_or_create_shop_by_wallet(g.wallet.id)
+ shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
if not shop:
- return "", HTTPStatus.NOT_FOUND
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
updated_shop = await set_method(shop.id, method, "\n".join(wordlist))
if not updated_shop:
- return "", HTTPStatus.NOT_FOUND
+ raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
ShopCounter.reset(updated_shop)
- return "", HTTPStatus.OK
diff --git a/lnbits/extensions/paywall/__init__.py b/lnbits/extensions/paywall/__init__.py
index cf9570a1..af1fab63 100644
--- a/lnbits/extensions/paywall/__init__.py
+++ b/lnbits/extensions/paywall/__init__.py
@@ -1,12 +1,16 @@
-from quart import Blueprint
+from fastapi import APIRouter
+
from lnbits.db import Database
+from lnbits.helpers import template_renderer
db = Database("ext_paywall")
-paywall_ext: Blueprint = Blueprint(
- "paywall", __name__, static_folder="static", template_folder="templates"
-)
+paywall_ext: APIRouter = APIRouter(prefix="/paywall", tags=["Paywall"])
+
+
+def paywall_renderer():
+ return template_renderer(["lnbits/extensions/paywall/templates"])
-from .views_api import * # noqa
from .views import * # noqa
+from .views_api import * # noqa
diff --git a/lnbits/extensions/paywall/crud.py b/lnbits/extensions/paywall/crud.py
index b12cc1ec..cb9e210d 100644
--- a/lnbits/extensions/paywall/crud.py
+++ b/lnbits/extensions/paywall/crud.py
@@ -3,25 +3,25 @@ from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
-from .models import Paywall
+from .models import CreatePaywall, Paywall
-async def create_paywall(
- *,
- wallet_id: str,
- url: str,
- memo: str,
- description: Optional[str] = None,
- amount: int = 0,
- remembers: bool = True,
-) -> Paywall:
+async def create_paywall(wallet_id: str, data: CreatePaywall) -> Paywall:
paywall_id = urlsafe_short_hash()
await db.execute(
"""
- INSERT INTO paywalls (id, wallet, url, memo, description, amount, remembers)
+ INSERT INTO paywall.paywalls (id, wallet, url, memo, description, amount, remembers)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
- (paywall_id, wallet_id, url, memo, description, amount, int(remembers)),
+ (
+ paywall_id,
+ wallet_id,
+ data.url,
+ data.memo,
+ data.description,
+ data.amount,
+ int(data.remembers),
+ ),
)
paywall = await get_paywall(paywall_id)
@@ -30,8 +30,9 @@ async def create_paywall(
async def get_paywall(paywall_id: str) -> Optional[Paywall]:
- row = await db.fetchone("SELECT * FROM paywalls WHERE id = ?", (paywall_id,))
-
+ row = await db.fetchone(
+ "SELECT * FROM paywall.paywalls WHERE id = ?", (paywall_id,)
+ )
return Paywall.from_row(row) if row else None
@@ -41,11 +42,11 @@ async def get_paywalls(wallet_ids: Union[str, List[str]]) -> List[Paywall]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
- f"SELECT * FROM paywalls WHERE wallet IN ({q})", (*wallet_ids,)
+ f"SELECT * FROM paywall.paywalls WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Paywall.from_row(row) for row in rows]
async def delete_paywall(paywall_id: str) -> None:
- await db.execute("DELETE FROM paywalls WHERE id = ?", (paywall_id,))
+ await db.execute("DELETE FROM paywall.paywalls WHERE id = ?", (paywall_id,))
diff --git a/lnbits/extensions/paywall/migrations.py b/lnbits/extensions/paywall/migrations.py
index f2faae2b..fa91e409 100644
--- a/lnbits/extensions/paywall/migrations.py
+++ b/lnbits/extensions/paywall/migrations.py
@@ -1,20 +1,19 @@
-from sqlalchemy.exc import OperationalError # type: ignore
-
-
async def m001_initial(db):
"""
Initial paywalls table.
"""
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS paywalls (
+ CREATE TABLE paywall.paywalls (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
secret TEXT NOT NULL,
url TEXT NOT NULL,
memo TEXT NOT NULL,
amount INTEGER NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
@@ -24,44 +23,41 @@ async def m002_redux(db):
"""
Creates an improved paywalls table and migrates the existing data.
"""
- try:
- await db.execute("SELECT remembers FROM paywalls")
+ await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old")
+ await db.execute(
+ """
+ CREATE TABLE paywall.paywalls (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ url TEXT NOT NULL,
+ memo TEXT NOT NULL,
+ description TEXT NULL,
+ amount INTEGER DEFAULT 0,
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """,
+ remembers INTEGER DEFAULT 0,
+ extras TEXT NULL
+ );
+ """
+ )
- except OperationalError:
- await db.execute("ALTER TABLE paywalls RENAME TO paywalls_old")
+ for row in [
+ list(row) for row in await db.fetchall("SELECT * FROM paywall.paywalls_old")
+ ]:
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS paywalls (
- id TEXT PRIMARY KEY,
- wallet TEXT NOT NULL,
- url TEXT NOT NULL,
- memo TEXT NOT NULL,
- description TEXT NULL,
- amount INTEGER DEFAULT 0,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now')),
- remembers INTEGER DEFAULT 0,
- extras TEXT NULL
- );
- """
- )
- await db.execute("CREATE INDEX IF NOT EXISTS wallet_idx ON paywalls (wallet)")
-
- for row in [
- list(row) for row in await db.fetchall("SELECT * FROM paywalls_old")
- ]:
- await db.execute(
- """
- INSERT INTO paywalls (
- id,
- wallet,
- url,
- memo,
- amount,
- time
- )
- VALUES (?, ?, ?, ?, ?, ?)
- """,
- (row[0], row[1], row[3], row[4], row[5], row[6]),
+ INSERT INTO paywall.paywalls (
+ id,
+ wallet,
+ url,
+ memo,
+ amount,
+ time
)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (row[0], row[1], row[3], row[4], row[5], row[6]),
+ )
- await db.execute("DROP TABLE paywalls_old")
+ await db.execute("DROP TABLE paywall.paywalls_old")
diff --git a/lnbits/extensions/paywall/models.py b/lnbits/extensions/paywall/models.py
index d7f2451d..7082c541 100644
--- a/lnbits/extensions/paywall/models.py
+++ b/lnbits/extensions/paywall/models.py
@@ -1,15 +1,33 @@
import json
-
from sqlite3 import Row
-from typing import NamedTuple, Optional
+from typing import Optional
+
+from fastapi import Query
+from pydantic import BaseModel
-class Paywall(NamedTuple):
+class CreatePaywall(BaseModel):
+ url: str = Query(...)
+ memo: str = Query(...)
+ description: str = Query(None)
+ amount: int = Query(..., ge=0)
+ remembers: bool = Query(...)
+
+
+class CreatePaywallInvoice(BaseModel):
+ amount: int = Query(..., ge=1)
+
+
+class CheckPaywallInvoice(BaseModel):
+ payment_hash: str = Query(...)
+
+
+class Paywall(BaseModel):
id: str
wallet: str
url: str
memo: str
- description: str
+ description: Optional[str]
amount: int
time: int
remembers: bool
diff --git a/lnbits/extensions/paywall/templates/paywall/_api_docs.html b/lnbits/extensions/paywall/templates/paywall/_api_docs.html
index 1157fa46..ceadf2f0 100644
--- a/lnbits/extensions/paywall/templates/paywall/_api_docs.html
+++ b/lnbits/extensions/paywall/templates/paywall/_api_docs.html
@@ -18,7 +18,7 @@
Curl example
curl -X GET {{ request.url_root }}api/v1/paywalls -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ user.wallets[0].inkey }}"
@@ -52,7 +52,7 @@
<string>, "memo": <string>, "description": <string>,
"amount": <integer>, "remembers": <boolean>}' -H
"Content-type: application/json" -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
+ user.wallets[0].adminkey }}"
@@ -139,7 +139,7 @@
curl -X DELETE {{ request.url_root
}}api/v1/paywalls/<paywall_id> -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
+ user.wallets[0].adminkey }}"
diff --git a/lnbits/extensions/paywall/templates/paywall/display.html b/lnbits/extensions/paywall/templates/paywall/display.html
index 391f4e84..b15db83e 100644
--- a/lnbits/extensions/paywall/templates/paywall/display.html
+++ b/lnbits/extensions/paywall/templates/paywall/display.html
@@ -24,7 +24,7 @@
dense
flat
icon="check"
- color="deep-purple"
+ color="primary"
type="submit"
@click="createInvoice"
:disabled="userAmount < paywallAmount || paymentReq"
@@ -77,8 +77,8 @@
mixins: [windowMixin],
data: function () {
return {
- userAmount: {{ paywall.amount }},
- paywallAmount: {{ paywall.amount }},
+ userAmount: '{{ paywall.amount }}',
+ paywallAmount: '{{ paywall.amount }}',
paymentReq: null,
redirectUrl: null,
paymentDialog: {
@@ -89,7 +89,9 @@
},
computed: {
amount: function () {
- return (this.paywallAmount > this.userAmount) ? this.paywallAmount : this.userAmount
+ return this.paywallAmount > this.userAmount
+ ? this.paywallAmount
+ : this.userAmount
}
},
methods: {
@@ -102,48 +104,55 @@
},
createInvoice: function () {
var self = this
-
- axios
- .post(
- '/paywall/api/v1/paywalls/{{ paywall.id }}/invoice',
- {amount: this.amount}
+ LNbits.api
+ .request(
+ 'POST',
+ '/paywall/api/v1/paywalls/invoice/{{ paywall.id }}',
+ 'filler',
+ {
+ amount: self.amount
+ }
)
.then(function (response) {
- self.paymentReq = response.data.payment_request.toUpperCase()
+ if (response.data) {
+ self.paymentReq = response.data.payment_request.toUpperCase()
+ self.paymentDialog.dismissMsg = self.$q.notify({
+ timeout: 0,
+ message: 'Waiting for payment...'
+ })
- self.paymentDialog.dismissMsg = self.$q.notify({
- timeout: 0,
- message: 'Waiting for payment...'
- })
-
- self.paymentDialog.checker = setInterval(function () {
- axios
- .post(
- '/paywall/api/v1/paywalls/{{ paywall.id }}/check_invoice',
- {payment_hash: response.data.payment_hash}
- )
- .then(function (res) {
- if (res.data.paid) {
- self.cancelPayment()
- self.redirectUrl = res.data.url
- if (res.data.remembers) {
- self.$q.localStorage.set(
- 'lnbits.paywall.{{ paywall.id }}',
- res.data.url
- )
+ self.paymentDialog.checker = setInterval(function () {
+ LNbits.api
+ .request(
+ 'POST',
+ '/paywall/api/v1/paywalls/check_invoice/{{ paywall.id }}',
+ 'filler',
+ {payment_hash: response.data.payment_hash}
+ )
+ .then(function (response) {
+ if (response.data) {
+ if (response.data.paid) {
+ self.cancelPayment()
+ self.redirectUrl = response.data.url
+ if (response.data.remembers) {
+ self.$q.localStorage.set(
+ 'lnbits.paywall.{{ paywall.id }}',
+ response.data.url
+ )
+ }
+ self.$q.notify({
+ type: 'positive',
+ message: 'Payment received!',
+ icon: null
+ })
+ }
}
-
- self.$q.notify({
- type: 'positive',
- message: 'Payment received!',
- icon: null
- })
- }
- })
- .catch(function (error) {
- LNbits.utils.notifyApiError(error)
- })
- }, 2000)
+ })
+ .catch(function (error) {
+ LNbits.utils.notifyApiError(error)
+ })
+ }, 2000)
+ }
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
diff --git a/lnbits/extensions/paywall/templates/paywall/index.html b/lnbits/extensions/paywall/templates/paywall/index.html
index fdda6cf5..482d1465 100644
--- a/lnbits/extensions/paywall/templates/paywall/index.html
+++ b/lnbits/extensions/paywall/templates/paywall/index.html
@@ -4,7 +4,7 @@
- New paywall
@@ -86,7 +86,9 @@
- LNbits paywall extension
+
+ {{SITE_TITLE}} paywall extension
+
@@ -141,7 +143,7 @@
@@ -157,7 +159,7 @@
Create paywall ")
-async def display(paywall_id):
- paywall = await get_paywall(paywall_id) or abort(
- HTTPStatus.NOT_FOUND, "Paywall does not exist."
+@paywall_ext.get("/")
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return paywall_renderer().TemplateResponse(
+ "paywall/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@paywall_ext.get("/{paywall_id}")
+async def display(request: Request, paywall_id):
+ paywall = await get_paywall(paywall_id)
+ if not paywall:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
+ )
+ return paywall_renderer().TemplateResponse(
+ "paywall/display.html", {"request": request, "paywall": paywall}
)
- return await render_template("paywall/display.html", paywall=paywall)
diff --git a/lnbits/extensions/paywall/views_api.py b/lnbits/extensions/paywall/views_api.py
index 45c80af4..3d1c2575 100644
--- a/lnbits/extensions/paywall/views_api.py
+++ b/lnbits/extensions/paywall/views_api.py
@@ -1,82 +1,70 @@
-from quart import g, jsonify, request
from http import HTTPStatus
+from fastapi import Depends, Query
+from starlette.exceptions import HTTPException
+
from lnbits.core.crud import get_user, get_wallet
-from lnbits.core.services import create_invoice, check_invoice_status
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
+from lnbits.core.services import check_invoice_status, create_invoice
+from lnbits.decorators import WalletTypeInfo, get_key_type
from . import paywall_ext
-from .crud import create_paywall, get_paywall, get_paywalls, delete_paywall
+from .crud import create_paywall, delete_paywall, get_paywall, get_paywalls
+from .models import CheckPaywallInvoice, CreatePaywall, CreatePaywallInvoice
-@paywall_ext.route("/api/v1/paywalls", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_paywalls():
- wallet_ids = [g.wallet.id]
+@paywall_ext.get("/api/v1/paywalls")
+async def api_paywalls(
+ wallet: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
+):
+ wallet_ids = [wallet.wallet.id]
- if "all_wallets" in request.args:
- wallet_ids = (await get_user(g.wallet.user)).wallet_ids
+ if all_wallets:
+ wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
- return (
- jsonify([paywall._asdict() for paywall in await get_paywalls(wallet_ids)]),
- HTTPStatus.OK,
- )
+ return [paywall.dict() for paywall in await get_paywalls(wallet_ids)]
-@paywall_ext.route("/api/v1/paywalls", methods=["POST"])
-@api_check_wallet_key("invoice")
-@api_validate_post_request(
- schema={
- "url": {"type": "string", "empty": False, "required": True},
- "memo": {"type": "string", "empty": False, "required": True},
- "description": {
- "type": "string",
- "empty": True,
- "nullable": True,
- "required": False,
- },
- "amount": {"type": "integer", "min": 0, "required": True},
- "remembers": {"type": "boolean", "required": True},
- }
-)
-async def api_paywall_create():
- paywall = await create_paywall(wallet_id=g.wallet.id, **g.data)
- return jsonify(paywall._asdict()), HTTPStatus.CREATED
+@paywall_ext.post("/api/v1/paywalls")
+async def api_paywall_create(
+ data: CreatePaywall, wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ paywall = await create_paywall(wallet_id=wallet.wallet.id, data=data)
+ return paywall.dict()
-@paywall_ext.route("/api/v1/paywalls/", methods=["DELETE"])
-@api_check_wallet_key("invoice")
-async def api_paywall_delete(paywall_id):
+@paywall_ext.delete("/api/v1/paywalls/{paywall_id}")
+async def api_paywall_delete(
+ paywall_id, wallet: WalletTypeInfo = Depends(get_key_type)
+):
paywall = await get_paywall(paywall_id)
if not paywall:
- return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
+ )
- if paywall.wallet != g.wallet.id:
- return jsonify({"message": "Not your paywall."}), HTTPStatus.FORBIDDEN
+ if paywall.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your paywall."
+ )
await delete_paywall(paywall_id)
-
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-@paywall_ext.route("/api/v1/paywalls//invoice", methods=["POST"])
-@api_validate_post_request(
- schema={"amount": {"type": "integer", "min": 1, "required": True}}
-)
-async def api_paywall_create_invoice(paywall_id):
+@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")
+async def api_paywall_create_invoice(
+ data: CreatePaywallInvoice,
+ paywall_id: str = Query(None)
+):
paywall = await get_paywall(paywall_id)
-
- if g.data["amount"] < paywall.amount:
- return (
- jsonify({"message": f"Minimum amount is {paywall.amount} sat."}),
- HTTPStatus.BAD_REQUEST,
+ if data.amount < paywall.amount:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"Minimum amount is {paywall.amount} sat.",
)
-
try:
- amount = (
- g.data["amount"] if g.data["amount"] > paywall.amount else paywall.amount
- )
+ amount = data.amount if data.amount > paywall.amount else paywall.amount
payment_hash, payment_request = await create_invoice(
wallet_id=paywall.wallet,
amount=amount,
@@ -84,38 +72,29 @@ async def api_paywall_create_invoice(paywall_id):
extra={"tag": "paywall"},
)
except Exception as e:
- return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
+ raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
- return (
- jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
- HTTPStatus.CREATED,
- )
+ return {"payment_hash": payment_hash, "payment_request": payment_request}
-@paywall_ext.route("/api/v1/paywalls//check_invoice", methods=["POST"])
-@api_validate_post_request(
- schema={"payment_hash": {"type": "string", "empty": False, "required": True}}
-)
-async def api_paywal_check_invoice(paywall_id):
+@paywall_ext.post("/api/v1/paywalls/check_invoice/{paywall_id}")
+async def api_paywal_check_invoice(data: CheckPaywallInvoice, paywall_id: str = Query(None)):
paywall = await get_paywall(paywall_id)
-
+ payment_hash = data.payment_hash
if not paywall:
- return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
-
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Paywall does not exist."
+ )
try:
- status = await check_invoice_status(paywall.wallet, g.data["payment_hash"])
+ status = await check_invoice_status(paywall.wallet, payment_hash)
is_paid = not status.pending
except Exception:
- return jsonify({"paid": False}), HTTPStatus.OK
+ return {"paid": False}
if is_paid:
wallet = await get_wallet(paywall.wallet)
- payment = await wallet.get_payment(g.data["payment_hash"])
+ payment = await wallet.get_payment(payment_hash)
await payment.set_pending(False)
- return (
- jsonify({"paid": True, "url": paywall.url, "remembers": paywall.remembers}),
- HTTPStatus.OK,
- )
-
- return jsonify({"paid": False}), HTTPStatus.OK
+ return {"paid": True, "url": paywall.url, "remembers": paywall.remembers}
+ return {"paid": False}
diff --git a/lnbits/extensions/satsdice/README.md b/lnbits/extensions/satsdice/README.md
new file mode 100644
index 00000000..c2419930
--- /dev/null
+++ b/lnbits/extensions/satsdice/README.md
@@ -0,0 +1,5 @@
+# satsdice
+
+## Create staic LNURL powered satsdices
+
+Gambling is dangerous, flip responsibly
diff --git a/lnbits/extensions/satsdice/__init__.py b/lnbits/extensions/satsdice/__init__.py
new file mode 100644
index 00000000..c20b032e
--- /dev/null
+++ b/lnbits/extensions/satsdice/__init__.py
@@ -0,0 +1,17 @@
+from fastapi import APIRouter
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+
+db = Database("ext_satsdice")
+
+satsdice_ext: APIRouter = APIRouter(prefix="/satsdice", tags=["satsdice"])
+
+
+def satsdice_renderer():
+ return template_renderer(["lnbits/extensions/satsdice/templates"])
+
+
+from .lnurl import * # noqa
+from .views import * # noqa
+from .views_api import * # noqa
diff --git a/lnbits/extensions/satsdice/config.json b/lnbits/extensions/satsdice/config.json
new file mode 100644
index 00000000..e4c2eddb
--- /dev/null
+++ b/lnbits/extensions/satsdice/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Sats Dice",
+ "short_description": "LNURL Satoshi dice",
+ "icon": "casino",
+ "contributors": ["arcbtc"]
+}
diff --git a/lnbits/extensions/satsdice/crud.py b/lnbits/extensions/satsdice/crud.py
new file mode 100644
index 00000000..7da5a1f1
--- /dev/null
+++ b/lnbits/extensions/satsdice/crud.py
@@ -0,0 +1,280 @@
+from datetime import datetime
+from typing import List, Optional, Union
+
+from lnbits.helpers import urlsafe_short_hash
+
+from . import db
+from .models import (
+ CreateSatsDiceLink,
+ CreateSatsDicePayment,
+ CreateSatsDiceWithdraw,
+ HashCheck,
+ satsdiceLink,
+ satsdicePayment,
+ satsdiceWithdraw,
+)
+
+
+async def create_satsdice_pay(wallet_id: str, data: CreateSatsDiceLink) -> satsdiceLink:
+ satsdice_id = urlsafe_short_hash()
+ await db.execute(
+ """
+ INSERT INTO satsdice.satsdice_pay (
+ id,
+ wallet,
+ title,
+ base_url,
+ min_bet,
+ max_bet,
+ amount,
+ served_meta,
+ served_pr,
+ multiplier,
+ chance,
+ haircut,
+ open_time
+ )
+ VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?)
+ """,
+ (
+ satsdice_id,
+ wallet_id,
+ data.title,
+ data.base_url,
+ data.min_bet,
+ data.max_bet,
+ data.multiplier,
+ data.chance,
+ data.haircut,
+ int(datetime.now().timestamp()),
+ ),
+ )
+ link = await get_satsdice_pay(satsdice_id)
+ assert link, "Newly created link couldn't be retrieved"
+ return link
+
+
+async def get_satsdice_pay(link_id: str) -> Optional[satsdiceLink]:
+ row = await db.fetchone(
+ "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)
+ )
+ return satsdiceLink(**row) if row else None
+
+
+async def get_satsdice_pays(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 satsdice.satsdice_pay WHERE wallet IN ({q})
+ ORDER BY id
+ """,
+ (*wallet_ids,),
+ )
+ return [satsdiceLink(**row) for row in rows]
+
+
+async def update_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?",
+ (*kwargs.values(), link_id),
+ )
+ row = await db.fetchone(
+ "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)
+ )
+ return satsdiceLink(**row) if row else None
+
+
+async def increment_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]:
+ q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?",
+ (*kwargs.values(), link_id),
+ )
+ row = await db.fetchone(
+ "SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)
+ )
+ return satsdiceLink(**row) if row else None
+
+
+async def delete_satsdice_pay(link_id: int) -> None:
+ await db.execute("DELETE FROM satsdice.satsdice_pay WHERE id = ?", (link_id,))
+
+
+##################SATSDICE PAYMENT LINKS
+
+
+async def create_satsdice_payment(data: CreateSatsDicePayment) -> satsdicePayment:
+ await db.execute(
+ """
+ INSERT INTO satsdice.satsdice_payment (
+ payment_hash,
+ satsdice_pay,
+ value,
+ paid,
+ lost
+ )
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (data["payment_hash"], data["satsdice_pay"], data["value"], False, False),
+ )
+ payment = await get_satsdice_payment(data["payment_hash"])
+ assert payment, "Newly created withdraw couldn't be retrieved"
+ return payment
+
+
+async def get_satsdice_payment(payment_hash: str) -> Optional[satsdicePayment]:
+ row = await db.fetchone(
+ "SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?",
+ (payment_hash,),
+ )
+ return satsdicePayment(**row) if row else None
+
+
+async def update_satsdice_payment(
+ payment_hash: int, **kwargs
+) -> Optional[satsdicePayment]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+
+ await db.execute(
+ f"UPDATE satsdice.satsdice_payment SET {q} WHERE payment_hash = ?",
+ (bool(*kwargs.values()), payment_hash),
+ )
+ row = await db.fetchone(
+ "SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?",
+ (payment_hash,),
+ )
+ return satsdicePayment(**row) if row else None
+
+
+##################SATSDICE WITHDRAW LINKS
+
+
+async def create_satsdice_withdraw(data: CreateSatsDiceWithdraw) -> satsdiceWithdraw:
+ await db.execute(
+ """
+ INSERT INTO satsdice.satsdice_withdraw (
+ id,
+ satsdice_pay,
+ value,
+ unique_hash,
+ k1,
+ open_time,
+ used
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ data["payment_hash"],
+ data["satsdice_pay"],
+ data["value"],
+ urlsafe_short_hash(),
+ urlsafe_short_hash(),
+ int(datetime.now().timestamp()),
+ data["used"],
+ ),
+ )
+ withdraw = await get_satsdice_withdraw(data["payment_hash"], 0)
+ assert withdraw, "Newly created withdraw couldn't be retrieved"
+ return withdraw
+
+
+async def get_satsdice_withdraw(withdraw_id: str, num=0) -> Optional[satsdiceWithdraw]:
+ row = await db.fetchone(
+ "SELECT * FROM satsdice.satsdice_withdraw WHERE id = ?", (withdraw_id,)
+ )
+ if not row:
+ return None
+
+ withdraw = []
+ for item in row:
+ withdraw.append(item)
+ withdraw.append(num)
+ return satsdiceWithdraw(**row)
+
+
+async def get_satsdice_withdraw_by_hash(
+ unique_hash: str, num=0
+) -> Optional[satsdiceWithdraw]:
+ row = await db.fetchone(
+ "SELECT * FROM satsdice.satsdice_withdraw WHERE unique_hash = ?", (unique_hash,)
+ )
+ if not row:
+ return None
+
+ withdraw = []
+ for item in row:
+ withdraw.append(item)
+ withdraw.append(num)
+ return satsdiceWithdraw(**row)
+
+
+async def get_satsdice_withdraws(
+ wallet_ids: Union[str, List[str]]
+) -> List[satsdiceWithdraw]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"SELECT * FROM satsdice.satsdice_withdraw WHERE wallet IN ({q})",
+ (*wallet_ids,),
+ )
+
+ return [satsdiceWithdraw(**row) for row in rows]
+
+
+async def update_satsdice_withdraw(
+ withdraw_id: str, **kwargs
+) -> Optional[satsdiceWithdraw]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE satsdice.satsdice_withdraw SET {q} WHERE id = ?",
+ (*kwargs.values(), withdraw_id),
+ )
+ row = await db.fetchone(
+ "SELECT * FROM satsdice.satsdice_withdraw WHERE id = ?", (withdraw_id,)
+ )
+ return satsdiceWithdraw(**row) if row else None
+
+
+async def delete_satsdice_withdraw(withdraw_id: str) -> None:
+ await db.execute(
+ "DELETE FROM satsdice.satsdice_withdraw WHERE id = ?", (withdraw_id,)
+ )
+
+
+async def create_withdraw_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
+ await db.execute(
+ """
+ INSERT INTO satsdice.hash_checkw (
+ id,
+ lnurl_id
+ )
+ VALUES (?, ?)
+ """,
+ (the_hash, lnurl_id),
+ )
+ hashCheck = await get_withdraw_hash_checkw(the_hash, lnurl_id)
+ return hashCheck
+
+
+async def get_withdraw_hash_checkw(the_hash: str, lnurl_id: str) -> Optional[HashCheck]:
+ rowid = await db.fetchone(
+ "SELECT * FROM satsdice.hash_checkw WHERE id = ?", (the_hash,)
+ )
+ rowlnurl = await db.fetchone(
+ "SELECT * FROM satsdice.hash_checkw WHERE lnurl_id = ?", (lnurl_id,)
+ )
+ if not rowlnurl:
+ await create_withdraw_hash_check(the_hash, lnurl_id)
+ return {"lnurl": True, "hash": False}
+ else:
+ if not rowid:
+ await create_withdraw_hash_check(the_hash, lnurl_id)
+ return {"lnurl": True, "hash": False}
+ else:
+ return {"lnurl": True, "hash": True}
diff --git a/lnbits/extensions/satsdice/lnurl.py b/lnbits/extensions/satsdice/lnurl.py
new file mode 100644
index 00000000..03d20502
--- /dev/null
+++ b/lnbits/extensions/satsdice/lnurl.py
@@ -0,0 +1,159 @@
+import hashlib
+import json
+import math
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.param_functions import Query
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse # type: ignore
+
+from lnbits.core.services import create_invoice, pay_invoice
+
+from . import satsdice_ext
+from .crud import (
+ create_satsdice_payment,
+ get_satsdice_pay,
+ get_satsdice_withdraw_by_hash,
+ update_satsdice_withdraw,
+)
+from .models import CreateSatsDicePayment
+
+##############LNURLP STUFF
+
+
+@satsdice_ext.get(
+ "/api/v1/lnurlp/{link_id}",
+ response_class=HTMLResponse,
+ name="satsdice.lnurlp_response",
+)
+async def api_lnurlp_response(req: Request, link_id: str = Query(None)):
+ link = await get_satsdice_pay(link_id)
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="LNURL-pay not found."
+ )
+ payResponse = {
+ "tag": "payRequest",
+ "callback": req.url_for("satsdice.api_lnurlp_callback", link_id=link.id),
+ "metadata": link.lnurlpay_metadata,
+ "minSendable": math.ceil(link.min_bet * 1) * 1000,
+ "maxSendable": round(link.max_bet * 1) * 1000,
+ }
+ return json.dumps(payResponse)
+
+
+@satsdice_ext.get(
+ "/api/v1/lnurlp/cb/{link_id}",
+ response_class=HTMLResponse,
+ name="satsdice.api_lnurlp_callback",
+)
+async def api_lnurlp_callback(
+ req: Request, link_id: str = Query(None), amount: str = Query(None)
+):
+ link = await get_satsdice_pay(link_id)
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="LNURL-pay not found."
+ )
+
+ min, max = link.min_bet, link.max_bet
+ min = link.min_bet * 1000
+ max = link.max_bet * 1000
+
+ amount_received = int(amount or 0)
+ if amount_received < min:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail=f"Amount {amount_received} is smaller than minimum {min}.",
+ )
+ elif amount_received > max:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail=f"Amount {amount_received} is greater than maximum {max}.",
+ )
+
+ payment_hash, payment_request = await create_invoice(
+ wallet_id=link.wallet,
+ amount=int(amount_received / 1000),
+ memo="Satsdice bet",
+ description_hash=hashlib.sha256(
+ link.lnurlpay_metadata.encode("utf-8")
+ ).digest(),
+ extra={"tag": "satsdice", "link": link.id, "comment": "comment"},
+ )
+
+ success_action = link.success_action(payment_hash=payment_hash, req=req)
+
+ data: CreateSatsDicePayment = {
+ "satsdice_pay": link.id,
+ "value": amount_received / 1000,
+ "payment_hash": payment_hash,
+ }
+
+ await create_satsdice_payment(data)
+ payResponse = {"pr": payment_request, "successAction": success_action, "routes": []}
+
+ return json.dumps(payResponse)
+
+
+##############LNURLW STUFF
+
+
+@satsdice_ext.get(
+ "/api/v1/lnurlw/{unique_hash}",
+ response_class=HTMLResponse,
+ name="satsdice.lnurlw_response",
+)
+async def api_lnurlw_response(req: Request, unique_hash: str = Query(None)):
+ link = await get_satsdice_withdraw_by_hash(unique_hash)
+
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="LNURL-satsdice not found."
+ )
+ if link.used:
+ raise HTTPException(status_code=HTTPStatus.OK, detail="satsdice is spent.")
+ url = req.url_for("satsdice.api_lnurlw_callback", unique_hash=link.unique_hash)
+ withdrawResponse = {
+ "tag": "withdrawRequest",
+ "callback": url,
+ "k1": link.k1,
+ "minWithdrawable": link.value * 1000,
+ "maxWithdrawable": link.value * 1000,
+ "defaultDescription": "Satsdice winnings!",
+ }
+ return json.dumps(withdrawResponse)
+
+
+# CALLBACK
+
+
+@satsdice_ext.get(
+ "/api/v1/lnurlw/cb/{unique_hash}",
+ status_code=HTTPStatus.OK,
+ name="satsdice.api_lnurlw_callback",
+)
+async def api_lnurlw_callback(
+ req: Request,
+ unique_hash: str = Query(None),
+ k1: str = Query(None),
+ pr: str = Query(None),
+):
+
+ link = await get_satsdice_withdraw_by_hash(unique_hash)
+ if not link:
+ return {"status": "ERROR", "reason": "no withdraw"}
+ if link.used:
+ return {"status": "ERROR", "reason": "spent"}
+ paylink = await get_satsdice_pay(link.satsdice_pay)
+
+ await update_satsdice_withdraw(link.id, used=1)
+ await pay_invoice(
+ wallet_id=paylink.wallet,
+ payment_request=pr,
+ max_sat=link.value,
+ extra={"tag": "withdraw"},
+ )
+
+ return {"status": "OK"}
diff --git a/lnbits/extensions/satsdice/migrations.py b/lnbits/extensions/satsdice/migrations.py
new file mode 100644
index 00000000..61298241
--- /dev/null
+++ b/lnbits/extensions/satsdice/migrations.py
@@ -0,0 +1,73 @@
+async def m001_initial(db):
+ """
+ Creates an improved satsdice table and migrates the existing data.
+ """
+ await db.execute(
+ """
+ CREATE TABLE satsdice.satsdice_pay (
+ id TEXT PRIMARY KEY,
+ wallet TEXT,
+ title TEXT,
+ min_bet INTEGER,
+ max_bet INTEGER,
+ amount INTEGER DEFAULT 0,
+ served_meta INTEGER NOT NULL,
+ served_pr INTEGER NOT NULL,
+ multiplier FLOAT,
+ haircut FLOAT,
+ chance FLOAT,
+ base_url TEXT,
+ open_time INTEGER
+ );
+ """
+ )
+
+
+async def m002_initial(db):
+ """
+ Creates an improved satsdice table and migrates the existing data.
+ """
+ await db.execute(
+ """
+ CREATE TABLE satsdice.satsdice_withdraw (
+ id TEXT PRIMARY KEY,
+ satsdice_pay TEXT,
+ value INTEGER DEFAULT 1,
+ unique_hash TEXT UNIQUE,
+ k1 TEXT,
+ open_time INTEGER,
+ used INTEGER DEFAULT 0
+ );
+ """
+ )
+
+
+async def m003_initial(db):
+ """
+ Creates an improved satsdice table and migrates the existing data.
+ """
+ await db.execute(
+ """
+ CREATE TABLE satsdice.satsdice_payment (
+ payment_hash TEXT PRIMARY KEY,
+ satsdice_pay TEXT,
+ value INTEGER,
+ paid BOOL DEFAULT FALSE,
+ lost BOOL DEFAULT FALSE
+ );
+ """
+ )
+
+
+async def m004_make_hash_check(db):
+ """
+ Creates a hash check table.
+ """
+ await db.execute(
+ """
+ CREATE TABLE satsdice.hash_checkw (
+ id TEXT PRIMARY KEY,
+ lnurl_id TEXT
+ );
+ """
+ )
diff --git a/lnbits/extensions/satsdice/models.py b/lnbits/extensions/satsdice/models.py
new file mode 100644
index 00000000..fd9af74f
--- /dev/null
+++ b/lnbits/extensions/satsdice/models.py
@@ -0,0 +1,136 @@
+import json
+from sqlite3 import Row
+from typing import Dict, Optional
+
+from fastapi import Request
+from fastapi.param_functions import Query
+from lnurl import Lnurl, LnurlWithdrawResponse
+from lnurl import encode as lnurl_encode # type: ignore
+from lnurl.types import LnurlPayMetadata # type: ignore
+from pydantic import BaseModel
+from pydantic.main import BaseModel
+
+
+class satsdiceLink(BaseModel):
+ id: str
+ wallet: str
+ title: str
+ min_bet: int
+ max_bet: int
+ amount: int
+ served_meta: int
+ served_pr: int
+ multiplier: float
+ haircut: float
+ chance: float
+ base_url: str
+ open_time: int
+
+ def lnurl(self, req: Request) -> str:
+ return lnurl_encode(req.url_for("satsdice.lnurlp_response", link_id=self.id))
+
+ @classmethod
+ def from_row(cls, row: Row) -> "satsdiceLink":
+ data = dict(row)
+ return cls(**data)
+
+ @property
+ def lnurlpay_metadata(self) -> LnurlPayMetadata:
+ return LnurlPayMetadata(
+ json.dumps(
+ [
+ [
+ "text/plain",
+ f"{self.title} (Chance: {self.chance}%, Multiplier: {self.multiplier})",
+ ]
+ ]
+ )
+ )
+
+ def success_action(self, payment_hash: str, req: Request) -> Optional[Dict]:
+ url = req.url_for(
+ "satsdice.displaywin", link_id=self.id, payment_hash=payment_hash
+ )
+ return {"tag": "url", "description": "Check the attached link", "url": url}
+
+
+class satsdicePayment(BaseModel):
+ payment_hash: str
+ satsdice_pay: str
+ value: int
+ paid: bool
+ lost: bool
+
+
+class satsdiceWithdraw(BaseModel):
+ id: str
+ satsdice_pay: str
+ value: int
+ unique_hash: str
+ k1: str
+ open_time: int
+ used: int
+
+ def lnurl(self, req: Request) -> Lnurl:
+ return lnurl_encode(
+ req.url_for("satsdice.lnurlw_response", unique_hash=self.unique_hash)
+ )
+
+ @property
+ def is_spent(self) -> bool:
+ return self.used >= 1
+
+ @property
+ def lnurl_response(self, req: Request) -> LnurlWithdrawResponse:
+ url = req.url_for("satsdice.api_lnurlw_callback", unique_hash=self.unique_hash)
+ withdrawResponse = {
+ "tag": "withdrawRequest",
+ "callback": url,
+ "k1": self.k1,
+ "minWithdrawable": self.value * 1000,
+ "maxWithdrawable": self.value * 1000,
+ "defaultDescription": "Satsdice winnings!",
+ }
+ return withdrawResponse
+
+
+class HashCheck(BaseModel):
+ id: str
+ lnurl_id: str
+
+ @classmethod
+ def from_row(cls, row: Row) -> "Hash":
+ return cls(**dict(row))
+
+
+class CreateSatsDiceLink(BaseModel):
+ wallet: str = Query(None)
+ title: str = Query(None)
+ base_url: str = Query(None)
+ min_bet: str = Query(None)
+ max_bet: str = Query(None)
+ multiplier: float = Query(0)
+ chance: float = Query(0)
+ haircut: int = Query(0)
+
+
+class CreateSatsDicePayment(BaseModel):
+ satsdice_pay: str = Query(None)
+ value: int = Query(0)
+ payment_hash: str = Query(None)
+
+
+class CreateSatsDiceWithdraw(BaseModel):
+ payment_hash: str = Query(None)
+ satsdice_pay: str = Query(None)
+ value: int = Query(0)
+ used: int = Query(0)
+
+
+class CreateSatsDiceWithdraws(BaseModel):
+ title: str = Query(None)
+ min_satsdiceable: int = Query(0)
+ max_satsdiceable: int = Query(0)
+ uses: int = Query(0)
+ wait_time: str = Query(None)
+ is_unique: bool = Query(False)
diff --git a/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html
new file mode 100644
index 00000000..fb43b90d
--- /dev/null
+++ b/lnbits/extensions/satsdice/templates/satsdice/_api_docs.html
@@ -0,0 +1,194 @@
+
+
+
+
+ GET /satsdice/api/v1/links
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ [<satsdice_link_object>, ...]
+ Curl example
+ curl -X GET {{ request.base_url }}api/v1/links -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ GET
+ /satsdice/api/v1/links/<satsdice_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 201 CREATED (application/json)
+
+ {"lnurl": <string>}
+ Curl example
+ curl -X GET {{ request.base_url }}api/v1/links/<satsdice_id> -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ POST /satsdice/api/v1/links
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Body (application/json)
+ {"title": <string>, "min_satsdiceable": <integer>,
+ "max_satsdiceable": <integer>, "uses": <integer>,
+ "wait_time": <integer>, "is_unique": <boolean>}
+
+ Returns 201 CREATED (application/json)
+
+ {"lnurl": <string>}
+ Curl example
+ curl -X POST {{ request.base_url }}api/v1/links -d '{"title":
+ <string>, "min_satsdiceable": <integer>,
+ "max_satsdiceable": <integer>, "uses": <integer>,
+ "wait_time": <integer>, "is_unique": <boolean>}' -H
+ "Content-type: application/json" -H "X-Api-Key: {{
+ user.wallets[0].adminkey }}"
+
+
+
+
+
+
+
+ PUT
+ /satsdice/api/v1/links/<satsdice_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Body (application/json)
+ {"title": <string>, "min_satsdiceable": <integer>,
+ "max_satsdiceable": <integer>, "uses": <integer>,
+ "wait_time": <integer>, "is_unique": <boolean>}
+
+ Returns 200 OK (application/json)
+
+ {"lnurl": <string>}
+ Curl example
+ curl -X PUT {{ request.base_url }}api/v1/links/<satsdice_id> -d
+ '{"title": <string>, "min_satsdiceable": <integer>,
+ "max_satsdiceable": <integer>, "uses": <integer>,
+ "wait_time": <integer>, "is_unique": <boolean>}' -H
+ "Content-type: application/json" -H "X-Api-Key: {{
+ user.wallets[0].adminkey }}"
+
+
+
+
+
+
+
+ DELETE
+ /satsdice/api/v1/links/<satsdice_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Returns 204 NO CONTENT
+
+ Curl example
+ curl -X DELETE {{ request.base_url }}api/v1/links/<satsdice_id>
+ -H "X-Api-Key: {{ user.wallets[0].adminkey }}"
+
+
+
+
+
+
+
+ GET
+ /satsdice/api/v1/links/<the_hash>/<lnurl_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 201 CREATED (application/json)
+
+ {"status": <bool>}
+ Curl example
+ curl -X GET {{ request.base_url
+ }}api/v1/links/<the_hash>/<lnurl_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
+
+
+
+
+
+
+
+ GET
+ /satsdice/img/<lnurl_id>
+ Curl example
+ curl -X GET {{ request.base_url }}/satsdice/img/<lnurl_id>"
+
+
+
+
+
diff --git a/lnbits/extensions/satsdice/templates/satsdice/_lnurl.html b/lnbits/extensions/satsdice/templates/satsdice/_lnurl.html
new file mode 100644
index 00000000..20b67cab
--- /dev/null
+++ b/lnbits/extensions/satsdice/templates/satsdice/_lnurl.html
@@ -0,0 +1,29 @@
+
+
+
+
+ WARNING: LNURL must be used over https or TOR
+ LNURL is a range of lightning-network standards that allow us to use
+ lightning-network differently. An LNURL satsdice is the permission for
+ someone to pull a certain amount of funds from a lightning wallet. In
+ this extension time is also added - an amount can be satsdice over a
+ period of time. A typical use case for an LNURL satsdice is a faucet,
+ although it is a very powerful technology, with much further reaching
+ implications. For example, an LNURL satsdice could be minted to pay for
+ a subscription service.
+
+
+ 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.
+
+ Check
+ Awesome LNURL
+ for further information.
+
+
+
diff --git a/lnbits/extensions/satsdice/templates/satsdice/display.html b/lnbits/extensions/satsdice/templates/satsdice/display.html
new file mode 100644
index 00000000..d4238e30
--- /dev/null
+++ b/lnbits/extensions/satsdice/templates/satsdice/display.html
@@ -0,0 +1,63 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+
+
+
+
+
+
+
+
+ Copy Satsdice LNURL
+
+
+
+
+
+
+
+
+ Chance of winning: {% raw %}{{ chance }}{% endraw %}, Amount
+ multiplier: {{ multiplier }}
+
+
+ Use a LNURL compatible bitcoin wallet to play the satsdice.
+
+
+
+
+ {% include "satsdice/_lnurl.html" %}
+
+
+
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
diff --git a/lnbits/extensions/satsdice/templates/satsdice/displaywin.html b/lnbits/extensions/satsdice/templates/satsdice/displaywin.html
new file mode 100644
index 00000000..aa4f1375
--- /dev/null
+++ b/lnbits/extensions/satsdice/templates/satsdice/displaywin.html
@@ -0,0 +1,56 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+
+
+
+
+
+
+
+
+ Copy winnings LNURL
+
+
+
+
+
+
+
+
+ Congrats! You have won {{ value }}sats (you must claim the sats now)
+
+
+ Use a LNURL compatible bitcoin wallet to play the satsdice.
+
+
+
+
+ {% include "satsdice/_lnurl.html" %}
+
+
+
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
diff --git a/lnbits/extensions/satsdice/templates/satsdice/error.html b/lnbits/extensions/satsdice/templates/satsdice/error.html
new file mode 100644
index 00000000..1c8fc618
--- /dev/null
+++ b/lnbits/extensions/satsdice/templates/satsdice/error.html
@@ -0,0 +1,48 @@
+{% extends "public.html" %} {% from "macros.jinja" import window_vars with
+context %}{% block page %}
+
+
+
+
+
+ {% if lost %}
+
+ You lost. Play again?
+
+ {% endif %} {% if paid %}
+
+ Winnings spent. Play again?
+
+ {% endif %}
+
+
+
+
+
+
+
+
+{% endblock %} {% block scripts %}{{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/satsdice/templates/satsdice/index.html b/lnbits/extensions/satsdice/templates/satsdice/index.html
new file mode 100644
index 00000000..eb52b6b2
--- /dev/null
+++ b/lnbits/extensions/satsdice/templates/satsdice/index.html
@@ -0,0 +1,528 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+ New satsdice
+
+
+
+
+
+
+
+ satsdices
+
+
+
+ {% raw %}
+
+
+
+ Title
+ Min bet
+ Max bet
+ Multiplier
+ Haircut
+ Chance
+
+
+
+
+
+
+
+
+
+ {{ props.row.title }}
+ {{ props.row.min_bet }}
+ {{ props.row.max_bet }}
+ *{{ props.row.multiplier }}
+ {{ props.row.haircut }}
+ {{ props.row.chance }}%
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} Sats Dice extension
+
+
+
+
+
+ {% include "satsdice/_api_docs.html" %}
+
+ {% include "satsdice/_lnurl.html" %}
+
+
+
+
+
+
+
+
+
+
+ {% raw %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Multipler: x{{ multiValue }}, Chance of winning: {{ chanceValueCalc
+ | percent }}
+
+
+
+
+
+
+ Update flip link
+ Create satsdice
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }}
+ {{ qrCodeDialog.data.currency }} price: {{
+ fiatRates[qrCodeDialog.data.currency] ?
+ fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}
+ Accepts comments: {{ qrCodeDialog.data.comments }}
+ Dispatches webhook to: {{ qrCodeDialog.data.webhook
+ }}
+ On success: {{ qrCodeDialog.data.success }}
+
+ {% endraw %}
+
+ Copy Satsdice LNURL
+ Copy shareable link
+
+ Launch shareable link
+ Print Satsdice
+ Close
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+
+{% endblock %}
diff --git a/lnbits/extensions/satsdice/views.py b/lnbits/extensions/satsdice/views.py
new file mode 100644
index 00000000..72e24867
--- /dev/null
+++ b/lnbits/extensions/satsdice/views.py
@@ -0,0 +1,139 @@
+import random
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.param_functions import Query
+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.core.views.api import api_payment
+from lnbits.decorators import check_user_exists
+
+from . import satsdice_ext, satsdice_renderer
+from .crud import (
+ create_satsdice_withdraw,
+ get_satsdice_pay,
+ get_satsdice_payment,
+ get_satsdice_withdraw,
+ update_satsdice_payment,
+)
+from .models import CreateSatsDiceWithdraw, satsdiceLink
+
+templates = Jinja2Templates(directory="templates")
+
+
+@satsdice_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return satsdice_renderer().TemplateResponse(
+ "satsdice/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@satsdice_ext.get("/{link_id}", response_class=HTMLResponse)
+async def display(request: Request, link_id: str = Query(None)):
+ link = await get_satsdice_pay(link_id)
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="satsdice link does not exist."
+ )
+
+ return satsdice_renderer().TemplateResponse(
+ "satsdice/display.html",
+ {
+ "request": request,
+ "chance": link.chance,
+ "multiplier": link.multiplier,
+ "lnurl": link.lnurl(request),
+ "unique": True,
+ },
+ )
+
+
+@satsdice_ext.get(
+ "/win/{link_id}/{payment_hash}",
+ name="satsdice.displaywin",
+ response_class=HTMLResponse,
+)
+async def displaywin(
+ request: Request, link_id: str = Query(None), payment_hash: str = Query(None)
+):
+ satsdicelink = await get_satsdice_pay(link_id)
+ if not satsdicelink:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="satsdice link does not exist."
+ )
+ withdrawLink = await get_satsdice_withdraw(payment_hash)
+ payment = await get_satsdice_payment(payment_hash)
+ if payment.lost:
+ return satsdice_renderer().TemplateResponse(
+ "satsdice/error.html",
+ {"request": request, "link": satsdicelink.id, "paid": False, "lost": True},
+ )
+ if withdrawLink:
+ return satsdice_renderer().TemplateResponse(
+ "satsdice/displaywin.html",
+ {
+ "request": request,
+ "value": withdrawLink.value,
+ "chance": satsdicelink.chance,
+ "multiplier": satsdicelink.multiplier,
+ "lnurl": withdrawLink.lnurl(request),
+ "paid": False,
+ "lost": False,
+ },
+ )
+ rand = random.randint(0, 100)
+ chance = satsdicelink.chance
+ status = await api_payment(payment_hash)
+ if not rand < chance or not status["paid"]:
+ await update_satsdice_payment(payment_hash, lost=1)
+ return satsdice_renderer().TemplateResponse(
+ "satsdice/error.html",
+ {"request": request, "link": satsdicelink.id, "paid": False, "lost": True},
+ )
+ await update_satsdice_payment(payment_hash, paid=1)
+ paylink = await get_satsdice_payment(payment_hash)
+
+ data: CreateSatsDiceWithdraw = {
+ "satsdice_pay": satsdicelink.id,
+ "value": paylink.value * satsdicelink.multiplier,
+ "payment_hash": payment_hash,
+ "used": 0,
+ }
+
+ withdrawLink = await create_satsdice_withdraw(data)
+ return satsdice_renderer().TemplateResponse(
+ "satsdice/displaywin.html",
+ {
+ "request": request,
+ "value": withdrawLink.value,
+ "chance": satsdicelink.chance,
+ "multiplier": satsdicelink.multiplier,
+ "lnurl": withdrawLink.lnurl(request),
+ "paid": False,
+ "lost": False,
+ },
+ )
+
+
+@satsdice_ext.get("/img/{link_id}", response_class=HTMLResponse)
+async def img(link_id):
+ link = await get_satsdice_pay(link_id) or abort(
+ HTTPStatus.NOT_FOUND, "satsdice link does not exist."
+ )
+ qr = pyqrcode.create(link.lnurl)
+ stream = BytesIO()
+ qr.svg(stream, scale=3)
+ return (
+ stream.getvalue(),
+ 200,
+ {
+ "Content-Type": "image/svg+xml",
+ "Cache-Control": "no-cache, no-store, must-revalidate",
+ "Pragma": "no-cache",
+ "Expires": "0",
+ },
+ )
diff --git a/lnbits/extensions/satsdice/views_api.py b/lnbits/extensions/satsdice/views_api.py
new file mode 100644
index 00000000..bccaa5ff
--- /dev/null
+++ b/lnbits/extensions/satsdice/views_api.py
@@ -0,0 +1,127 @@
+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 . import satsdice_ext
+from .crud import (
+ create_satsdice_pay,
+ delete_satsdice_pay,
+ get_satsdice_pay,
+ get_satsdice_pays,
+ update_satsdice_pay,
+)
+from .models import CreateSatsDiceLink, CreateSatsDiceWithdraws, satsdiceLink
+
+################LNURL pay
+
+
+@satsdice_ext.get("/api/v1/links")
+async def api_links(
+ request: 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:
+ links = await get_satsdice_pays(wallet_ids)
+
+ return [{**link.dict(), **{"lnurl": link.lnurl(request)}} for link in links]
+ except LnurlInvalidUrl:
+ raise HTTPException(
+ status_code=HTTPStatus.UPGRADE_REQUIRED,
+ detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
+ )
+
+
+@satsdice_ext.get("/api/v1/links/{link_id}")
+async def api_link_retrieve(
+ link_id: str = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ link = await get_satsdice_pay(link_id)
+
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
+ )
+
+ if link.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your pay link."
+ )
+
+ return {**link.dict(), **{"lnurl": link.lnurl}}
+
+
+@satsdice_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
+@satsdice_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
+async def api_link_create_or_update(
+ data: CreateSatsDiceLink,
+ wallet: WalletTypeInfo = Depends(get_key_type),
+ link_id: str = Query(None),
+):
+ if data.min_bet > data.max_bet:
+ raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Bad request")
+ if link_id:
+ link = await get_satsdice_pay(link_id)
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Satsdice does not exist"
+ )
+
+ if link.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Come on, seriously, this isn't your satsdice!",
+ )
+
+ data.wallet = wallet.wallet.id
+ link = await update_satsdice_pay(link_id, **data.dict())
+ else:
+ link = await create_satsdice_pay(wallet_id=wallet.wallet.id, data=data)
+
+ return {**link.dict(), **{"lnurl": link.lnurl}}
+
+
+@satsdice_ext.delete("/api/v1/links/{link_id}")
+async def api_link_delete(
+ wallet: WalletTypeInfo = Depends(get_key_type), link_id: str = Query(None)
+):
+ link = await get_satsdice_pay(link_id)
+
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
+ )
+
+ if link.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your pay link."
+ )
+
+ await delete_satsdice_pay(link_id)
+
+ return "", HTTPStatus.NO_CONTENT
+
+
+##########LNURL withdraw
+
+
+@satsdice_ext.get("/api/v1/withdraws/{the_hash}/{lnurl_id}")
+async def api_withdraw_hash_retrieve(
+ wallet: WalletTypeInfo = Depends(get_key_type),
+ lnurl_id: str = Query(None),
+ the_hash: str = Query(None),
+):
+ hashCheck = await get_withdraw_hash_check(the_hash, lnurl_id)
+ return hashCheck
diff --git a/lnbits/extensions/satspay/__init__.py b/lnbits/extensions/satspay/__init__.py
index 4bdaa2b6..f33f3aa5 100644
--- a/lnbits/extensions/satspay/__init__.py
+++ b/lnbits/extensions/satspay/__init__.py
@@ -1,13 +1,26 @@
-from quart import Blueprint
+import asyncio
+
+from fastapi import APIRouter
+
from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
db = Database("ext_satspay")
-satspay_ext: Blueprint = Blueprint(
- "satspay", __name__, static_folder="static", template_folder="templates"
-)
+satspay_ext: APIRouter = APIRouter(prefix="/satspay", tags=["satspay"])
-from .views_api import * # noqa
+def satspay_renderer():
+ return template_renderer(["lnbits/extensions/satspay/templates"])
+
+
+from .tasks import wait_for_paid_invoices
from .views import * # noqa
+from .views_api import * # noqa
+
+
+def satspay_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/satspay/config.json b/lnbits/extensions/satspay/config.json
index b8cd185a..beb0071c 100644
--- a/lnbits/extensions/satspay/config.json
+++ b/lnbits/extensions/satspay/config.json
@@ -1,5 +1,5 @@
{
- "name": "SatsPayServer",
+ "name": "SatsPay Server",
"short_description": "Create onchain and LN charges",
"icon": "payment",
"contributors": [
diff --git a/lnbits/extensions/satspay/crud.py b/lnbits/extensions/satspay/crud.py
index c4d28456..9deb3215 100644
--- a/lnbits/extensions/satspay/crud.py
+++ b/lnbits/extensions/satspay/crud.py
@@ -1,50 +1,43 @@
-from typing import List, Optional, Union
+from typing import List, Optional
+
+import httpx
+
+from lnbits.core.services import create_invoice
+from lnbits.core.views.api import api_payment
+from lnbits.helpers import urlsafe_short_hash
+
+from ..watchonly.crud import get_fresh_address, get_mempool, get_watch_wallet
# from lnbits.db import open_ext_db
from . import db
-from .models import Charges
-
-from lnbits.helpers import urlsafe_short_hash
-
-from quart import jsonify
-import httpx
-from lnbits.core.services import create_invoice, check_invoice_status
-from ..watchonly.crud import get_watch_wallet, get_fresh_address, get_mempool
-
+from .models import Charges, CreateCharge
###############CHARGES##########################
-async def create_charge(
- user: str,
- description: str = None,
- onchainwallet: Optional[str] = None,
- lnbitswallet: Optional[str] = None,
- webhook: Optional[str] = None,
- completelink: Optional[str] = None,
- completelinktext: Optional[str] = "Back to Merchant",
- time: Optional[int] = None,
- amount: Optional[int] = None,
-) -> Charges:
+async def create_charge(user: str, data: CreateCharge) -> Charges:
charge_id = urlsafe_short_hash()
- if onchainwallet:
- wallet = await get_watch_wallet(onchainwallet)
- onchain = await get_fresh_address(onchainwallet)
+ if data.onchainwallet:
+ wallet = await get_watch_wallet(data.onchainwallet)
+ onchain = await get_fresh_address(data.onchainwallet)
onchainaddress = onchain.address
else:
onchainaddress = None
- if lnbitswallet:
+ if data.lnbitswallet:
payment_hash, payment_request = await create_invoice(
- wallet_id=lnbitswallet, amount=amount, memo=charge_id
+ wallet_id=data.lnbitswallet,
+ amount=data.amount,
+ memo=charge_id,
+ extra={"tag": "charge"},
)
else:
payment_hash = None
payment_request = None
await db.execute(
"""
- INSERT INTO charges (
+ INSERT INTO satspay.charges (
id,
- user,
+ "user",
description,
onchainwallet,
onchainaddress,
@@ -63,17 +56,17 @@ async def create_charge(
(
charge_id,
user,
- description,
- onchainwallet,
+ data.description,
+ data.onchainwallet,
onchainaddress,
- lnbitswallet,
+ data.lnbitswallet,
payment_request,
payment_hash,
- webhook,
- completelink,
- completelinktext,
- time,
- amount,
+ data.webhook,
+ data.completelink,
+ data.completelinktext,
+ data.time,
+ data.amount,
0,
),
)
@@ -83,24 +76,26 @@ async def create_charge(
async def update_charge(charge_id: str, **kwargs) -> Optional[Charges]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
- f"UPDATE charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id)
+ f"UPDATE satspay.charges SET {q} WHERE id = ?", (*kwargs.values(), charge_id)
)
- row = await db.fetchone("SELECT * FROM charges WHERE id = ?", (charge_id,))
+ row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
return Charges.from_row(row) if row else None
async def get_charge(charge_id: str) -> Charges:
- row = await db.fetchone("SELECT * FROM charges WHERE id = ?", (charge_id,))
+ row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
return Charges.from_row(row) if row else None
async def get_charges(user: str) -> List[Charges]:
- rows = await db.fetchall("SELECT * FROM charges WHERE user = ?", (user,))
+ rows = await db.fetchall(
+ """SELECT * FROM satspay.charges WHERE "user" = ?""", (user,)
+ )
return [Charges.from_row(row) for row in rows]
async def delete_charge(charge_id: str) -> None:
- await db.execute("DELETE FROM charges WHERE id = ?", (charge_id,))
+ await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,))
async def check_address_balance(charge_id: str) -> List[Charges]:
@@ -119,10 +114,9 @@ async def check_address_balance(charge_id: str) -> List[Charges]:
except Exception:
pass
if charge.lnbitswallet:
- invoice_status = await check_invoice_status(
- charge.lnbitswallet, charge.payment_hash
- )
- if invoice_status.paid:
+ invoice_status = await api_payment(charge.payment_hash)
+
+ if invoice_status["paid"]:
return await update_charge(charge_id=charge_id, balance=charge.amount)
- row = await db.fetchone("SELECT * FROM charges WHERE id = ?", (charge_id,))
+ row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,))
return Charges.from_row(row) if row else None
diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py
index 79048f9e..87446c80 100644
--- a/lnbits/extensions/satspay/migrations.py
+++ b/lnbits/extensions/satspay/migrations.py
@@ -5,9 +5,9 @@ async def m001_initial(db):
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS charges (
+ CREATE TABLE satspay.charges (
id TEXT NOT NULL PRIMARY KEY,
- user TEXT,
+ "user" TEXT,
description TEXT,
onchainwallet TEXT,
onchainaddress TEXT,
@@ -20,7 +20,9 @@ async def m001_initial(db):
time INTEGER,
amount INTEGER,
balance INTEGER DEFAULT 0,
- timestamp TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ timestamp TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py
index a7bfa14f..7e8080dc 100644
--- a/lnbits/extensions/satspay/models.py
+++ b/lnbits/extensions/satspay/models.py
@@ -1,20 +1,34 @@
-from sqlite3 import Row
-from typing import NamedTuple
import time
+from sqlite3 import Row
+from typing import Optional
+
+from fastapi.param_functions import Query
+from pydantic import BaseModel
-class Charges(NamedTuple):
+class CreateCharge(BaseModel):
+ onchainwallet: str = Query(None)
+ lnbitswallet: str = Query(None)
+ description: str = Query(...)
+ webhook: str = Query(None)
+ completelink: str = Query(None)
+ completelinktext: str = Query(None)
+ time: int = Query(..., ge=1)
+ amount: int = Query(..., ge=1)
+
+
+class Charges(BaseModel):
id: str
user: str
- description: str
- onchainwallet: str
- onchainaddress: str
- lnbitswallet: str
- payment_request: str
- payment_hash: str
- webhook: str
- completelink: str
- completelinktext: str
+ description: Optional[str]
+ onchainwallet: Optional[str]
+ onchainaddress: Optional[str]
+ lnbitswallet: Optional[str]
+ payment_request: Optional[str]
+ payment_hash: Optional[str]
+ webhook: Optional[str]
+ completelink: Optional[str]
+ completelinktext: Optional[str] = "Back to Merchant"
time: int
amount: int
balance: int
diff --git a/lnbits/extensions/satspay/tasks.py b/lnbits/extensions/satspay/tasks.py
new file mode 100644
index 00000000..87f74b7d
--- /dev/null
+++ b/lnbits/extensions/satspay/tasks.py
@@ -0,0 +1,30 @@
+import asyncio
+
+from lnbits.core.models import Payment
+from lnbits.extensions.satspay.crud import check_address_balance, get_charge
+from lnbits.tasks import register_invoice_listener
+
+# from .crud import get_ticket, set_ticket_paid
+
+
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
+
+ while True:
+ payment = await invoice_queue.get()
+ await on_invoice_paid(payment)
+
+
+async def on_invoice_paid(payment: Payment) -> None:
+ if "charge" != payment.extra.get("tag"):
+ # not a charge invoice
+ return
+
+ charge = await get_charge(payment.memo)
+ if not charge:
+ print("this should never happen", payment)
+ return
+
+ await payment.set_pending(False)
+ await check_address_balance(charge_id=charge.id)
diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html
index 526af7f3..d834db20 100644
--- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html
+++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html
@@ -32,12 +32,12 @@
[<charge_object>, ...]
Curl example
curl -X POST {{ request.url_root }}api/v1/charge -d
+ >curl -X POST {{ request.base_url }}api/v1/charge -d
'{"onchainwallet": <string, watchonly_wallet_id>,
"description": <string>, "webhook":<string>, "time":
<integer>, "amount": <integer>, "lnbitswallet":
<string, lnbits_wallet_id>}' -H "Content-type:
- application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
+ application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}"
@@ -60,12 +60,12 @@
[<charge_object>, ...]
Curl example
curl -X POST {{ request.url_root }}api/v1/charge/<charge_id>
+ >curl -X POST {{ request.base_url }}api/v1/charge/<charge_id>
-d '{"onchainwallet": <string, watchonly_wallet_id>,
"description": <string>, "webhook":<string>, "time":
<integer>, "amount": <integer>, "lnbitswallet":
<string, lnbits_wallet_id>}' -H "Content-type:
- application/json" -H "X-Api-Key: {{g.user.wallets[0].adminkey }}"
+ application/json" -H "X-Api-Key: {{user.wallets[0].adminkey }}"
@@ -89,8 +89,8 @@
[<charge_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v1/charge/<charge_id>
- -H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}api/v1/charge/<charge_id>
+ -H "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -112,8 +112,8 @@
[<charge_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v1/charges -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}api/v1/charges -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -137,9 +137,9 @@
Curl example
curl -X DELETE {{ request.url_root
+ >curl -X DELETE {{ request.base_url
}}api/v1/charge/<charge_id> -H "X-Api-Key: {{
- g.user.wallets[0].adminkey }}"
+ user.wallets[0].adminkey }}"
@@ -160,9 +160,9 @@
[<charge_object>, ...]
Curl example
curl -X GET {{ request.url_root
+ >curl -X GET {{ request.base_url
}}api/v1/charges/balance/<charge_id> -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ user.wallets[0].inkey }}"
diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html
index b3386074..8c577fbe 100644
--- a/lnbits/extensions/satspay/templates/satspay/display.html
+++ b/lnbits/extensions/satspay/templates/satspay/display.html
@@ -64,7 +64,7 @@
label="lightning⚡"
>
- bitcoin onchain payment method not available
+ bitcoin lightning payment method not available
- bitcoin lightning payment method not available
+ bitcoin onchain payment method not available
-
+
-
+
{% endblock %} {% block scripts %}
-
+
+{% endblock %}
diff --git a/lnbits/extensions/streamalerts/templates/streamalerts/index.html b/lnbits/extensions/streamalerts/templates/streamalerts/index.html
new file mode 100644
index 00000000..46d1bb31
--- /dev/null
+++ b/lnbits/extensions/streamalerts/templates/streamalerts/index.html
@@ -0,0 +1,502 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+ New Service
+
+
+
+
+
+
+
+ Services
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+ Redirect URI for Streamlabs
+
+
+ {{ col.value }}
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+ Donations
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} Stream Alerts extension
+
+
+
+
+ {% include "streamalerts/_api_docs.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Watch-Only extension MUST be activated and have a wallet
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Update Service
+
+ Create Service
+ Cancel
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/streamalerts/views.py b/lnbits/extensions/streamalerts/views.py
new file mode 100644
index 00000000..595b841e
--- /dev/null
+++ b/lnbits/extensions/streamalerts/views.py
@@ -0,0 +1,37 @@
+from http import HTTPStatus
+
+from fastapi.param_functions import Depends
+from fastapi.templating import Jinja2Templates
+from starlette.exceptions import HTTPException
+from starlette.requests import Request
+from starlette.responses import HTMLResponse
+
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import streamalerts_ext, streamalerts_renderer
+from .crud import get_service
+
+templates = Jinja2Templates(directory="templates")
+
+
+@streamalerts_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ """Return the extension's settings page"""
+ return streamalerts_renderer().TemplateResponse(
+ "streamalerts/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@streamalerts_ext.get("/{state}")
+async def donation(state, request: Request):
+ """Return the donation form for the Service corresponding to state"""
+ service = await get_service(0, by_state=state)
+ if not service:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Service does not exist."
+ )
+ return streamalerts_renderer().TemplateResponse(
+ "streamalerts/display.html",
+ {"request": request, "twitchuser": service.twitchuser, "service": service.id},
+ )
diff --git a/lnbits/extensions/streamalerts/views_api.py b/lnbits/extensions/streamalerts/views_api.py
new file mode 100644
index 00000000..0a678d8b
--- /dev/null
+++ b/lnbits/extensions/streamalerts/views_api.py
@@ -0,0 +1,265 @@
+from http import HTTPStatus
+
+from fastapi.params import Depends, Query
+from starlette.exceptions import HTTPException
+from starlette.requests import Request
+from starlette.responses import RedirectResponse
+
+from lnbits.core.crud import get_user
+from lnbits.decorators import WalletTypeInfo, get_key_type
+from lnbits.extensions.satspay.models import CreateCharge
+from lnbits.extensions.streamalerts.models import (
+ CreateDonation,
+ CreateService,
+ ValidateDonation,
+)
+from lnbits.utils.exchange_rates import btc_price
+
+from ..satspay.crud import create_charge, get_charge
+from . import streamalerts_ext
+from .crud import (
+ authenticate_service,
+ create_donation,
+ create_service,
+ delete_donation,
+ delete_service,
+ get_charge_details,
+ get_donation,
+ get_donations,
+ get_service,
+ get_service_redirect_uri,
+ get_services,
+ post_donation,
+ update_donation,
+ update_service,
+)
+
+
+@streamalerts_ext.post("/api/v1/services")
+async def api_create_service(
+ data: CreateService, wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ """Create a service, which holds data about how/where to post donations"""
+ try:
+ service = await create_service(data=data)
+ except Exception as e:
+ raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
+
+ return service.dict()
+
+
+@streamalerts_ext.get("/api/v1/getaccess/{service_id}")
+async def api_get_access(service_id, request: Request):
+ """Redirect to Streamlabs' Approve/Decline page for API access for Service
+ with service_id
+ """
+ service = await get_service(service_id)
+ if service:
+ redirect_uri = await get_service_redirect_uri(request, service_id)
+ params = {
+ "response_type": "code",
+ "client_id": service.client_id,
+ "redirect_uri": redirect_uri,
+ "scope": "donations.create",
+ "state": service.state,
+ }
+ endpoint_url = "https://streamlabs.com/api/v1.0/authorize/?"
+ querystring = "&".join([f"{key}={value}" for key, value in params.items()])
+ redirect_url = endpoint_url + querystring
+ return RedirectResponse(redirect_url)
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="Service does not exist!"
+ )
+
+
+@streamalerts_ext.get("/api/v1/authenticate/{service_id}")
+async def api_authenticate_service(
+ service_id, request: Request, code: str = Query(...), state: str = Query(...)
+):
+ """Endpoint visited via redirect during third party API authentication
+
+ If successful, an API access token will be added to the service, and
+ the user will be redirected to index.html.
+ """
+
+ service = await get_service(service_id)
+ if service.state != state:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="State doesn't match!"
+ )
+
+ redirect_uri = request.url.scheme + "://" + request.headers["Host"]
+ redirect_uri += f"/streamalerts/api/v1/authenticate/{service_id}"
+ url, success = await authenticate_service(service_id, code, redirect_uri)
+ if success:
+ return RedirectResponse(url)
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="Service already authenticated!"
+ )
+
+
+@streamalerts_ext.post("/api/v1/donations")
+async def api_create_donation(data: CreateDonation, request: Request):
+ """Take data from donation form and return satspay charge"""
+ # Currency is hardcoded while frotnend is limited
+ cur_code = "USD"
+ sats = data.sats
+ message = data.message
+ # Fiat amount is calculated here while frontend is limited
+ price = await btc_price(cur_code)
+ amount = sats * (10 ** (-8)) * price
+ webhook_base = request.url.scheme + "://" + request.headers["Host"]
+ service_id = data.service
+ service = await get_service(service_id)
+ charge_details = await get_charge_details(service.id)
+ name = data.name if data.name else "Anonymous"
+
+ description = f"{sats} sats donation from {name} to {service.twitchuser}"
+ create_charge_data = CreateCharge(
+ amount=sats,
+ completelink=f"https://twitch.tv/{service.twitchuser}",
+ completelinktext="Back to Stream!",
+ webhook=webhook_base + "/streamalerts/api/v1/postdonation",
+ description=description,
+ **charge_details
+ )
+ charge = await create_charge(user=charge_details["user"], data=create_charge_data)
+ await create_donation(
+ id=charge.id,
+ wallet=service.wallet,
+ message=message,
+ name=name,
+ cur_code=cur_code,
+ sats=data.sats,
+ amount=amount,
+ service=data.service,
+ )
+ return {"redirect_url": f"/satspay/{charge.id}"}
+
+
+@streamalerts_ext.post("/api/v1/postdonation")
+async def api_post_donation(request: Request, data: ValidateDonation):
+ """Post a paid donation to Stremalabs/StreamElements.
+ This endpoint acts as a webhook for the SatsPayServer extension."""
+
+ donation_id = data.id
+ charge = await get_charge(donation_id)
+ if charge and charge.paid:
+ return await post_donation(donation_id)
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="Not a paid charge!"
+ )
+
+
+@streamalerts_ext.get("/api/v1/services")
+async def api_get_services(g: WalletTypeInfo = Depends(get_key_type)):
+ """Return list of all services assigned to wallet with given invoice key"""
+ wallet_ids = (await get_user(g.wallet.user)).wallet_ids
+ services = []
+ for wallet_id in wallet_ids:
+ new_services = await get_services(wallet_id)
+ services += new_services if new_services else []
+ return [service.dict() for service in services] if services else []
+
+
+@streamalerts_ext.get("/api/v1/donations")
+async def api_get_donations(g: WalletTypeInfo = Depends(get_key_type)):
+ """Return list of all donations assigned to wallet with given invoice
+ key
+ """
+ wallet_ids = (await get_user(g.wallet.user)).wallet_ids
+ donations = []
+ for wallet_id in wallet_ids:
+ new_donations = await get_donations(wallet_id)
+ donations += new_donations if new_donations else []
+ return [donation.dict() for donation in donations] if donations else []
+
+
+@streamalerts_ext.put("/api/v1/donations/{donation_id}")
+async def api_update_donation(
+ data: CreateDonation, donation_id=None, g: WalletTypeInfo = Depends(get_key_type)
+):
+ """Update a donation with the data given in the request"""
+ if donation_id:
+ donation = await get_donation(donation_id)
+
+ if not donation:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Donation does not exist."
+ )
+
+ if donation.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your donation."
+ )
+
+ donation = await update_donation(donation_id, **data.dict())
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="No donation ID specified"
+ )
+
+ return donation.dict()
+
+
+@streamalerts_ext.put("/api/v1/services/{service_id}")
+async def api_update_service(
+ data: CreateService, service_id=None, g: WalletTypeInfo = Depends(get_key_type)
+):
+ """Update a service with the data given in the request"""
+ if service_id:
+ service = await get_service(service_id)
+
+ if not service:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Service does not exist."
+ )
+
+ if service.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your service."
+ )
+
+ service = await update_service(service_id, **data.dict())
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="No service ID specified"
+ )
+ return service.dict()
+
+
+@streamalerts_ext.delete("/api/v1/donations/{donation_id}")
+async def api_delete_donation(donation_id, g: WalletTypeInfo = Depends(get_key_type)):
+ """Delete the donation with the given donation_id"""
+ donation = await get_donation(donation_id)
+ if not donation:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="No donation with this ID!"
+ )
+ if donation.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not authorized to delete this donation!",
+ )
+ await delete_donation(donation_id)
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
+
+
+@streamalerts_ext.delete("/api/v1/services/{service_id}")
+async def api_delete_service(service_id, g: WalletTypeInfo = Depends(get_key_type)):
+ """Delete the service with the given service_id"""
+ service = await get_service(service_id)
+ if not service:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="No service with this ID!"
+ )
+ if service.wallet != g.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not authorized to delete this service!",
+ )
+ await delete_service(service_id)
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
diff --git a/lnbits/extensions/subdomains/__init__.py b/lnbits/extensions/subdomains/__init__.py
index 5013230c..fbaa8a4c 100644
--- a/lnbits/extensions/subdomains/__init__.py
+++ b/lnbits/extensions/subdomains/__init__.py
@@ -1,17 +1,25 @@
-from quart import Blueprint
+import asyncio
+
+from fastapi import APIRouter
+
from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
db = Database("ext_subdomains")
-subdomains_ext: Blueprint = Blueprint(
- "subdomains", __name__, static_folder="static", template_folder="templates"
-)
+subdomains_ext: APIRouter = APIRouter(prefix="/subdomains", tags=["subdomains"])
-from .views_api import * # noqa
+def subdomains_renderer():
+ return template_renderer(["lnbits/extensions/subdomains/templates"])
+
+
+from .tasks import wait_for_paid_invoices
from .views import * # noqa
+from .views_api import * # noqa
-from .tasks import register_listeners
-from lnbits.tasks import record_async
-subdomains_ext.record(record_async(register_listeners))
+def subdomains_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/subdomains/cloudflare.py b/lnbits/extensions/subdomains/cloudflare.py
index 9a0fc4cf..8ada2a90 100644
--- a/lnbits/extensions/subdomains/cloudflare.py
+++ b/lnbits/extensions/subdomains/cloudflare.py
@@ -28,7 +28,7 @@ async def cloudflare_create_subdomain(
"name": aRecord,
"content": ip,
"ttl": 0,
- "proxed": False,
+ "proxied": False,
},
timeout=40,
)
@@ -50,11 +50,7 @@ async def cloudflare_deletesubdomain(domain: Domains, domain_id: str):
}
async with httpx.AsyncClient() as client:
try:
- r = await client.delete(
- url + "/" + domain_id,
- headers=header,
- timeout=40,
- )
+ r = await client.delete(url + "/" + domain_id, headers=header, timeout=40)
cf_response = r.text
except AssertionError:
cf_response = "Error occured"
diff --git a/lnbits/extensions/subdomains/crud.py b/lnbits/extensions/subdomains/crud.py
index 6e2c2e7e..207e2d1d 100644
--- a/lnbits/extensions/subdomains/crud.py
+++ b/lnbits/extensions/subdomains/crud.py
@@ -3,36 +3,26 @@ from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
-from .models import Domains, Subdomains
+from .models import CreateDomain, Domains, Subdomains
-async def create_subdomain(
- payment_hash: str,
- wallet: str,
- domain: str,
- subdomain: str,
- email: str,
- ip: str,
- sats: int,
- duration: int,
- record_type: str,
-) -> Subdomains:
+async def create_subdomain(payment_hash, wallet, data: CreateDomain) -> Subdomains:
await db.execute(
"""
- INSERT INTO subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type)
+ INSERT INTO subdomains.subdomain (id, domain, email, subdomain, ip, wallet, sats, duration, paid, record_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payment_hash,
- domain,
- email,
- subdomain,
- ip,
+ data.domain,
+ data.email,
+ data.subdomain,
+ data.ip,
wallet,
- sats,
- duration,
+ data.sats,
+ data.duration,
False,
- record_type,
+ data.record_type,
),
)
@@ -43,13 +33,13 @@ async def create_subdomain(
async def set_subdomain_paid(payment_hash: str) -> Subdomains:
row = await db.fetchone(
- "SELECT s.*, d.domain as domain_name FROM subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?",
+ "SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN subdomains.domain d ON (s.domain = d.id) WHERE s.id = ?",
(payment_hash,),
)
if row[8] == False:
await db.execute(
"""
- UPDATE subdomain
+ UPDATE subdomains.subdomain
SET paid = true
WHERE id = ?
""",
@@ -62,7 +52,7 @@ async def set_subdomain_paid(payment_hash: str) -> Subdomains:
amount = domaindata.amountmade + row[8]
await db.execute(
"""
- UPDATE domain
+ UPDATE subdomains.domain
SET amountmade = ?
WHERE id = ?
""",
@@ -76,7 +66,7 @@ async def set_subdomain_paid(payment_hash: str) -> Subdomains:
async def get_subdomain(subdomain_id: str) -> Optional[Subdomains]:
row = await db.fetchone(
- "SELECT s.*, d.domain as domain_name FROM subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.id = ?",
+ "SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN subdomains.domain d ON (s.domain = d.id) WHERE s.id = ?",
(subdomain_id,),
)
return Subdomains(**row) if row else None
@@ -84,10 +74,9 @@ async def get_subdomain(subdomain_id: str) -> Optional[Subdomains]:
async def get_subdomainBySubdomain(subdomain: str) -> Optional[Subdomains]:
row = await db.fetchone(
- "SELECT s.*, d.domain as domain_name FROM subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.subdomain = ?",
+ "SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN subdomains.domain d ON (s.domain = d.id) WHERE s.subdomain = ?",
(subdomain,),
)
- print(row)
return Subdomains(**row) if row else None
@@ -97,7 +86,7 @@ async def get_subdomains(wallet_ids: Union[str, List[str]]) -> List[Subdomains]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
- f"SELECT s.*, d.domain as domain_name FROM subdomain s INNER JOIN domain d ON (s.domain = d.id) WHERE s.wallet IN ({q})",
+ f"SELECT s.*, d.domain as domain_name FROM subdomains.subdomain s INNER JOIN subdomains.domain d ON (s.domain = d.id) WHERE s.wallet IN ({q})",
(*wallet_ids,),
)
@@ -105,40 +94,30 @@ async def get_subdomains(wallet_ids: Union[str, List[str]]) -> List[Subdomains]:
async def delete_subdomain(subdomain_id: str) -> None:
- await db.execute("DELETE FROM subdomain WHERE id = ?", (subdomain_id,))
+ await db.execute("DELETE FROM subdomains.subdomain WHERE id = ?", (subdomain_id,))
# Domains
-async def create_domain(
- *,
- wallet: str,
- domain: str,
- cf_token: str,
- cf_zone_id: str,
- webhook: Optional[str] = None,
- description: str,
- cost: int,
- allowed_record_types: str,
-) -> Domains:
+async def create_domain(data: CreateDomain) -> Domains:
domain_id = urlsafe_short_hash()
await db.execute(
"""
- INSERT INTO domain (id, wallet, domain, webhook, cf_token, cf_zone_id, description, cost, amountmade, allowed_record_types)
+ INSERT INTO subdomains.domain (id, wallet, domain, webhook, cf_token, cf_zone_id, description, cost, amountmade, allowed_record_types)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
domain_id,
- wallet,
- domain,
- webhook,
- cf_token,
- cf_zone_id,
- description,
- cost,
+ data.wallet,
+ data.domain,
+ data.webhook,
+ data.cf_token,
+ data.cf_zone_id,
+ data.description,
+ data.cost,
0,
- allowed_record_types,
+ data.allowed_record_types,
),
)
@@ -150,15 +129,19 @@ async def create_domain(
async def update_domain(domain_id: str, **kwargs) -> Domains:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
- f"UPDATE domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id)
+ f"UPDATE subdomains.domain SET {q} WHERE id = ?", (*kwargs.values(), domain_id)
+ )
+ row = await db.fetchone(
+ "SELECT * FROM subdomains.domain WHERE id = ?", (domain_id,)
)
- row = await db.fetchone("SELECT * FROM domain WHERE id = ?", (domain_id,))
assert row, "Newly updated domain couldn't be retrieved"
return Domains(**row)
async def get_domain(domain_id: str) -> Optional[Domains]:
- row = await db.fetchone("SELECT * FROM domain WHERE id = ?", (domain_id,))
+ row = await db.fetchone(
+ "SELECT * FROM subdomains.domain WHERE id = ?", (domain_id,)
+ )
return Domains(**row) if row else None
@@ -168,11 +151,11 @@ async def get_domains(wallet_ids: Union[str, List[str]]) -> List[Domains]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
- f"SELECT * FROM domain WHERE wallet IN ({q})", (*wallet_ids,)
+ f"SELECT * FROM subdomains.domain WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Domains(**row) for row in rows]
async def delete_domain(domain_id: str) -> None:
- await db.execute("DELETE FROM domain WHERE id = ?", (domain_id,))
+ await db.execute("DELETE FROM subdomains.domain WHERE id = ?", (domain_id,))
diff --git a/lnbits/extensions/subdomains/migrations.py b/lnbits/extensions/subdomains/migrations.py
index 4864377d..292d1f18 100644
--- a/lnbits/extensions/subdomains/migrations.py
+++ b/lnbits/extensions/subdomains/migrations.py
@@ -2,7 +2,7 @@ async def m001_initial(db):
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS domain (
+ CREATE TABLE subdomains.domain (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
domain TEXT NOT NULL,
@@ -13,14 +13,16 @@ async def m001_initial(db):
cost INTEGER NOT NULL,
amountmade INTEGER NOT NULL,
allowed_record_types TEXT NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS subdomain (
+ CREATE TABLE subdomains.subdomain (
id TEXT PRIMARY KEY,
domain TEXT NOT NULL,
email TEXT NOT NULL,
@@ -31,7 +33,9 @@ async def m001_initial(db):
duration INTEGER NOT NULL,
paid BOOLEAN NOT NULL,
record_type TEXT NOT NULL,
- time TIMESTAMP NOT NULL DEFAULT (strftime('%s', 'now'))
+ time TIMESTAMP NOT NULL DEFAULT """
+ + db.timestamp_now
+ + """
);
"""
)
diff --git a/lnbits/extensions/subdomains/models.py b/lnbits/extensions/subdomains/models.py
index a519311e..17004504 100644
--- a/lnbits/extensions/subdomains/models.py
+++ b/lnbits/extensions/subdomains/models.py
@@ -1,7 +1,29 @@
-from typing import NamedTuple
+from fastapi.params import Query
+from pydantic.main import BaseModel
-class Domains(NamedTuple):
+class CreateDomain(BaseModel):
+ wallet: str = Query(...)
+ domain: str = Query(...)
+ cf_token: str = Query(...)
+ cf_zone_id: str = Query(...)
+ webhook: str = Query("")
+ description: str = Query(..., min_length=0)
+ cost: int = Query(..., ge=0)
+ allowed_record_types: str = Query(...)
+
+
+class CreateSubdomain(BaseModel):
+ domain: str = Query(...)
+ subdomain: str = Query(...)
+ email: str = Query(...)
+ ip: str = Query(...)
+ sats: int = Query(..., ge=0)
+ duration: int = Query(...)
+ record_type: str = Query(...)
+
+
+class Domains(BaseModel):
id: str
wallet: str
domain: str
@@ -15,7 +37,7 @@ class Domains(NamedTuple):
allowed_record_types: str
-class Subdomains(NamedTuple):
+class Subdomains(BaseModel):
id: str
wallet: str
domain: str
diff --git a/lnbits/extensions/subdomains/tasks.py b/lnbits/extensions/subdomains/tasks.py
index b15703fb..75223e82 100644
--- a/lnbits/extensions/subdomains/tasks.py
+++ b/lnbits/extensions/subdomains/tasks.py
@@ -1,24 +1,20 @@
-from http import HTTPStatus
-from quart.json import jsonify
-import trio
+import asyncio
+
import httpx
-from .crud import get_domain, set_subdomain_paid
-from lnbits.core.crud import get_user, get_wallet
-from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
+
from .cloudflare import cloudflare_create_subdomain
+from .crud import get_domain, set_subdomain_paid
-async def register_listeners():
- invoice_paid_chan_send, invoice_paid_chan_recv = trio.open_memory_channel(2)
- register_invoice_listener(invoice_paid_chan_send)
- await wait_for_paid_invoices(invoice_paid_chan_recv)
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue)
-
-async def wait_for_paid_invoices(invoice_paid_chan: trio.MemoryReceiveChannel):
- async for payment in invoice_paid_chan:
+ while True:
+ payment = await invoice_queue.get()
await on_invoice_paid(payment)
@@ -32,7 +28,7 @@ async def on_invoice_paid(payment: Payment) -> None:
domain = await get_domain(subdomain.domain)
### Create subdomain
- cf_response = cloudflare_create_subdomain(
+ cf_response = await cloudflare_create_subdomain(
domain=domain,
subdomain=subdomain.subdomain,
record_type=subdomain.record_type,
diff --git a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html
index e78ae4ac..b839c641 100644
--- a/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html
+++ b/lnbits/extensions/subdomains/templates/subdomains/_api_docs.html
@@ -11,8 +11,11 @@
Charge people for using your subdomain name...
- Are you the owner of cool-domain.com and want to sell
- cool-subdomain.cool-domain.com
+
+ More details
Created by, Kris
Submit
- New Domain
@@ -149,17 +149,19 @@
-
-
-
- LNbits Subdomain extension
-
-
-
- {% include "subdomains/_api_docs.html" %}
-
-
-
+
+
+
+
+
+ {{SITE_TITLE}} Subdomain extension
+
+
+
+
+ {% include "subdomains/_api_docs.html" %}
+
+
@@ -233,14 +235,14 @@
Update Form
Create Domain ")
-async def display(domain_id):
+@subdomains_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return subdomains_renderer().TemplateResponse(
+ "subdomains/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@subdomains_ext.get("/{domain_id}")
+async def display(request: Request, domain_id):
domain = await get_domain(domain_id)
if not domain:
- abort(HTTPStatus.NOT_FOUND, "Domain does not exist.")
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
+ )
allowed_records = (
domain.allowed_record_types.replace('"', "").replace(" ", "").split(",")
)
- print(allowed_records)
- return await render_template(
+
+ return subdomains_renderer().TemplateResponse(
"subdomains/display.html",
- domain_id=domain.id,
- domain_domain=domain.domain,
- domain_desc=domain.description,
- domain_cost=domain.cost,
- domain_allowed_record_types=allowed_records,
+ {
+ "request": request,
+ "domain_id": domain.id,
+ "domain_domain": domain.domain,
+ "domain_desc": domain.description,
+ "domain_cost": domain.cost,
+ "domain_allowed_record_types": allowed_records,
+ },
)
diff --git a/lnbits/extensions/subdomains/views_api.py b/lnbits/extensions/subdomains/views_api.py
index c11cd4be..ba87bc98 100644
--- a/lnbits/extensions/subdomains/views_api.py
+++ b/lnbits/extensions/subdomains/views_api.py
@@ -1,222 +1,190 @@
-from quart import g, jsonify, request
from http import HTTPStatus
+from fastapi import Query
+from fastapi.params import Depends
+from starlette.exceptions import HTTPException
+
from lnbits.core.crud import get_user
-from lnbits.core.services import create_invoice, check_invoice_status
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
+from lnbits.core.services import check_invoice_status, create_invoice
+from lnbits.decorators import WalletTypeInfo, get_key_type
+from lnbits.extensions.subdomains.models import CreateDomain, CreateSubdomain
from . import subdomains_ext
+from .cloudflare import cloudflare_create_subdomain, cloudflare_deletesubdomain
from .crud import (
- create_subdomain,
- get_subdomain,
- get_subdomains,
- delete_subdomain,
create_domain,
- update_domain,
+ create_subdomain,
+ delete_domain,
+ delete_subdomain,
get_domain,
get_domains,
- delete_domain,
+ get_subdomain,
get_subdomainBySubdomain,
+ get_subdomains,
+ update_domain,
)
-from .cloudflare import cloudflare_create_subdomain, cloudflare_deletesubdomain
-
# domainS
-@subdomains_ext.route("/api/v1/domains", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_domains():
+@subdomains_ext.get("/api/v1/domains")
+async def api_domains(
+ g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
+):
wallet_ids = [g.wallet.id]
- if "all_wallets" in request.args:
+ if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
- return (
- jsonify([domain._asdict() for domain in await get_domains(wallet_ids)]),
- HTTPStatus.OK,
- )
+ return [domain.dict() for domain in await get_domains(wallet_ids)]
-@subdomains_ext.route("/api/v1/domains", methods=["POST"])
-@subdomains_ext.route("/api/v1/domains/", methods=["PUT"])
-@api_check_wallet_key("invoice")
-@api_validate_post_request(
- schema={
- "wallet": {"type": "string", "empty": False, "required": True},
- "domain": {"type": "string", "empty": False, "required": True},
- "cf_token": {"type": "string", "empty": False, "required": True},
- "cf_zone_id": {"type": "string", "empty": False, "required": True},
- "webhook": {"type": "string", "empty": False, "required": False},
- "description": {"type": "string", "min": 0, "required": True},
- "cost": {"type": "integer", "min": 0, "required": True},
- "allowed_record_types": {"type": "string", "required": True},
- }
-)
-async def api_domain_create(domain_id=None):
+@subdomains_ext.post("/api/v1/domains")
+@subdomains_ext.put("/api/v1/domains/{domain_id}")
+async def api_domain_create(
+ data: CreateDomain, domain_id=None, g: WalletTypeInfo = Depends(get_key_type)
+):
if domain_id:
domain = await get_domain(domain_id)
if not domain:
- return jsonify({"message": "domain does not exist."}), HTTPStatus.NOT_FOUND
-
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
+ )
if domain.wallet != g.wallet.id:
- return jsonify({"message": "Not your domain."}), HTTPStatus.FORBIDDEN
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your domain."
+ )
- domain = await update_domain(domain_id, **g.data)
+ domain = await update_domain(domain_id, **data.dict())
else:
- domain = await create_domain(**g.data)
- return jsonify(domain._asdict()), HTTPStatus.CREATED
+ domain = await create_domain(data=data)
+ return domain.dict()
-@subdomains_ext.route("/api/v1/domains/", methods=["DELETE"])
-@api_check_wallet_key("invoice")
-async def api_domain_delete(domain_id):
+@subdomains_ext.delete("/api/v1/domains/{domain_id}")
+async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)):
domain = await get_domain(domain_id)
if not domain:
- return jsonify({"message": "domain does not exist."}), HTTPStatus.NOT_FOUND
-
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Domain does not exist."
+ )
if domain.wallet != g.wallet.id:
- return jsonify({"message": "Not your domain."}), HTTPStatus.FORBIDDEN
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain.")
await delete_domain(domain_id)
-
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
#########subdomains##########
-@subdomains_ext.route("/api/v1/subdomains", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_subdomains():
+@subdomains_ext.get("/api/v1/subdomains")
+async def api_subdomains(
+ all_wallets: bool = Query(False), g: WalletTypeInfo = Depends(get_key_type)
+):
wallet_ids = [g.wallet.id]
- if "all_wallets" in request.args:
+ if all_wallets:
wallet_ids = (await get_user(g.wallet.user)).wallet_ids
- return (
- jsonify([domain._asdict() for domain in await get_subdomains(wallet_ids)]),
- HTTPStatus.OK,
- )
+ return [domain.dict() for domain in await get_subdomains(wallet_ids)]
-@subdomains_ext.route("/api/v1/subdomains/", methods=["POST"])
-@api_validate_post_request(
- schema={
- "domain": {"type": "string", "empty": False, "required": True},
- "subdomain": {"type": "string", "empty": False, "required": True},
- "email": {"type": "string", "empty": True, "required": True},
- "ip": {"type": "string", "empty": False, "required": True},
- "sats": {"type": "integer", "min": 0, "required": True},
- "duration": {"type": "integer", "empty": False, "required": True},
- "record_type": {"type": "string", "empty": False, "required": True},
- }
-)
-async def api_subdomain_make_subdomain(domain_id):
+@subdomains_ext.post("/api/v1/subdomains/{domain_id}")
+async def api_subdomain_make_subdomain(domain_id, data: CreateSubdomain):
domain = await get_domain(domain_id)
# If the request is coming for the non-existant domain
if not domain:
- return jsonify({"message": "LNsubdomain does not exist."}), HTTPStatus.NOT_FOUND
-
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="LNsubdomain does not exist."
+ )
## If record_type is not one of the allowed ones reject the request
- if g.data["record_type"] not in domain.allowed_record_types:
- return (
- jsonify({"message": g.data["record_type"] + "Not a valid record"}),
- HTTPStatus.BAD_REQUEST,
+ if data.record_type not in domain.allowed_record_types:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"{data.record_type} not a valid record.",
)
## If domain already exist in our database reject it
- if await get_subdomainBySubdomain(g.data["subdomain"]) is not None:
- return (
- jsonify(
- {
- "message": g.data["subdomain"]
- + "."
- + domain.domain
- + " domain already taken"
- }
- ),
- HTTPStatus.BAD_REQUEST,
+ if await get_subdomainBySubdomain(data.subdomain) is not None:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"{data.subdomain}.{domain.domain} domain already taken.",
)
## Dry run cloudflare... (create and if create is sucessful delete it)
cf_response = await cloudflare_create_subdomain(
domain=domain,
- subdomain=g.data["subdomain"],
- record_type=g.data["record_type"],
- ip=g.data["ip"],
+ subdomain=data.subdomain,
+ record_type=data.record_type,
+ ip=data.ip,
)
if cf_response["success"] == True:
- cloudflare_deletesubdomain(domain=domain, domain_id=cf_response["result"]["id"])
+ await cloudflare_deletesubdomain(
+ domain=domain, domain_id=cf_response["result"]["id"]
+ )
else:
- return (
- jsonify(
- {
- "message": "Problem with cloudflare: "
- + cf_response["errors"][0]["message"]
- }
- ),
- HTTPStatus.BAD_REQUEST,
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f'Problem with cloudflare: {cf_response["errors"][0]["message"]}',
)
## ALL OK - create an invoice and return it to the user
- sats = g.data["sats"]
+ sats = data.sats
try:
payment_hash, payment_request = await create_invoice(
wallet_id=domain.wallet,
amount=sats,
- memo=f"subdomain {g.data['subdomain']}.{domain.domain} for {sats} sats for {g.data['duration']} days",
+ memo=f"subdomain {data.subdomain}.{domain.domain} for {sats} sats for {data.duration} days",
extra={"tag": "lnsubdomain"},
)
except Exception as e:
- return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
+ raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
subdomain = await create_subdomain(
- payment_hash=payment_hash, wallet=domain.wallet, **g.data
+ payment_hash=payment_hash, wallet=domain.wallet, data=data
)
if not subdomain:
- return (
- jsonify({"message": "LNsubdomain could not be fetched."}),
- HTTPStatus.NOT_FOUND,
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="LNsubdomain could not be fetched."
)
- return (
- jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
- HTTPStatus.OK,
- )
+ return {"payment_hash": payment_hash, "payment_request": payment_request}
-@subdomains_ext.route("/api/v1/subdomains/", methods=["GET"])
+@subdomains_ext.get("/api/v1/subdomains/{payment_hash}")
async def api_subdomain_send_subdomain(payment_hash):
subdomain = await get_subdomain(payment_hash)
try:
status = await check_invoice_status(subdomain.wallet, payment_hash)
is_paid = not status.pending
except Exception:
- return jsonify({"paid": False}), HTTPStatus.OK
+ return {"paid": False}
if is_paid:
- return jsonify({"paid": True}), HTTPStatus.OK
+ return {"paid": True}
- return jsonify({"paid": False}), HTTPStatus.OK
+ return {"paid": False}
-@subdomains_ext.route("/api/v1/subdomains/", methods=["DELETE"])
-@api_check_wallet_key("invoice")
-async def api_subdomain_delete(subdomain_id):
+@subdomains_ext.delete("/api/v1/subdomains/{subdomain_id}")
+async def api_subdomain_delete(subdomain_id, g: WalletTypeInfo = Depends(get_key_type)):
subdomain = await get_subdomain(subdomain_id)
if not subdomain:
- return jsonify({"message": "Paywall does not exist."}), HTTPStatus.NOT_FOUND
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="LNsubdomain does not exist."
+ )
if subdomain.wallet != g.wallet.id:
- return jsonify({"message": "Not your subdomain."}), HTTPStatus.FORBIDDEN
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your subdomain."
+ )
await delete_subdomain(subdomain_id)
-
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
diff --git a/lnbits/extensions/tipjar/README.md b/lnbits/extensions/tipjar/README.md
new file mode 100644
index 00000000..4965ec93
--- /dev/null
+++ b/lnbits/extensions/tipjar/README.md
@@ -0,0 +1,15 @@
+Tip Jars
+Accept tips in Bitcoin, with small messages attached!
+The TipJar extension allows you to integrate Bitcoin Lightning (and on-chain) tips into your website or social media!
+
+
+
+How to set it up
+
+1. Simply create a new Tip Jar with the desired details (onchain optional):
+
+1. Share the URL you get from this little button:
+
+
+
+And that's it already! Let the sats flow!
diff --git a/lnbits/extensions/tipjar/__init__.py b/lnbits/extensions/tipjar/__init__.py
new file mode 100644
index 00000000..a4b50c04
--- /dev/null
+++ b/lnbits/extensions/tipjar/__init__.py
@@ -0,0 +1,16 @@
+from fastapi import APIRouter
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+
+db = Database("ext_tipjar")
+
+tipjar_ext: APIRouter = APIRouter(prefix="/tipjar", tags=["tipjar"])
+
+
+def tipjar_renderer():
+ return template_renderer(["lnbits/extensions/tipjar/templates"])
+
+
+from .views import * # noqa
+from .views_api import * # noqa
diff --git a/lnbits/extensions/tipjar/config.json b/lnbits/extensions/tipjar/config.json
new file mode 100644
index 00000000..e48eb4ea
--- /dev/null
+++ b/lnbits/extensions/tipjar/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Tip Jar",
+ "short_description": "Accept Bitcoin donations, with messages attached!",
+ "icon": "favorite",
+ "contributors": ["Fittiboy"]
+}
diff --git a/lnbits/extensions/tipjar/crud.py b/lnbits/extensions/tipjar/crud.py
new file mode 100644
index 00000000..29e1a469
--- /dev/null
+++ b/lnbits/extensions/tipjar/crud.py
@@ -0,0 +1,122 @@
+from typing import Optional
+
+from lnbits.db import SQLITE
+
+from ..satspay.crud import delete_charge # type: ignore
+from . import db
+from .models import Tip, TipJar, createTipJar
+
+
+async def create_tip(
+ id: int, wallet: str, message: str, name: str, sats: int, tipjar: str
+) -> Tip:
+ """Create a new Tip"""
+ await db.execute(
+ """
+ INSERT INTO tipjar.Tips (
+ id,
+ wallet,
+ name,
+ message,
+ sats,
+ tipjar
+ )
+ VALUES (?, ?, ?, ?, ?, ?)
+ """,
+ (id, wallet, name, message, sats, tipjar),
+ )
+
+ tip = await get_tip(id)
+ assert tip, "Newly created tip couldn't be retrieved"
+ return tip
+
+
+async def create_tipjar(data: createTipJar) -> TipJar:
+ """Create a new TipJar"""
+
+ returning = "" if db.type == SQLITE else "RETURNING ID"
+ method = db.execute if db.type == SQLITE else db.fetchone
+
+ result = await (method)(
+ f"""
+ INSERT INTO tipjar.TipJars (
+ name,
+ wallet,
+ webhook,
+ onchain
+ )
+ VALUES (?, ?, ?, ?)
+ {returning}
+ """,
+ (data.name, data.wallet, data.webhook, data.onchain),
+ )
+ if db.type == SQLITE:
+ tipjar_id = result._result_proxy.lastrowid
+ else:
+ tipjar_id = result[0]
+
+ tipjar = await get_tipjar(tipjar_id)
+ assert tipjar
+ return tipjar
+
+
+async def get_tipjar(tipjar_id: int) -> Optional[TipJar]:
+ """Return a tipjar by ID"""
+ row = await db.fetchone("SELECT * FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
+ return TipJar(**row) if row else None
+
+
+async def get_tipjars(wallet_id: str) -> Optional[list]:
+ """Return all TipJars belonging assigned to the wallet_id"""
+ rows = await db.fetchall(
+ "SELECT * FROM tipjar.TipJars WHERE wallet = ?", (wallet_id,)
+ )
+ return [TipJar(**row) for row in rows] if rows else None
+
+
+async def delete_tipjar(tipjar_id: int) -> None:
+ """Delete a TipJar and all corresponding Tips"""
+ await db.execute("DELETE FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
+ rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE tipjar = ?", (tipjar_id,))
+ for row in rows:
+ await delete_tip(row["id"])
+
+
+async def get_tip(tip_id: str) -> Optional[Tip]:
+ """Return a Tip"""
+ row = await db.fetchone("SELECT * FROM tipjar.Tips WHERE id = ?", (tip_id,))
+ return Tip(**row) if row else None
+
+
+async def get_tips(wallet_id: str) -> Optional[list]:
+ """Return all Tips assigned to wallet_id"""
+ rows = await db.fetchall("SELECT * FROM tipjar.Tips WHERE wallet = ?", (wallet_id,))
+ return [Tip(**row) for row in rows] if rows else None
+
+
+async def delete_tip(tip_id: str) -> None:
+ """Delete a Tip and its corresponding statspay charge"""
+ await db.execute("DELETE FROM tipjar.Tips WHERE id = ?", (tip_id,))
+ await delete_charge(tip_id)
+
+
+async def update_tip(tip_id: str, **kwargs) -> Tip:
+ """Update a Tip"""
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE tipjar.Tips SET {q} WHERE id = ?", (*kwargs.values(), tip_id)
+ )
+ row = await db.fetchone("SELECT * FROM tipjar.Tips WHERE id = ?", (tip_id,))
+ assert row, "Newly updated tip couldn't be retrieved"
+ return Tip(**row)
+
+
+async def update_tipjar(tipjar_id: str, **kwargs) -> TipJar:
+ """Update a tipjar"""
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE tipjar.TipJars SET {q} WHERE id = ?", (*kwargs.values(), tipjar_id)
+ )
+ row = await db.fetchone("SELECT * FROM tipjar.TipJars WHERE id = ?", (tipjar_id,))
+ assert row, "Newly updated tipjar couldn't be retrieved"
+ return TipJar(**row)
diff --git a/lnbits/extensions/tipjar/helpers.py b/lnbits/extensions/tipjar/helpers.py
new file mode 100644
index 00000000..7214e19c
--- /dev/null
+++ b/lnbits/extensions/tipjar/helpers.py
@@ -0,0 +1,20 @@
+from lnbits.core.crud import get_wallet
+
+from .crud import get_tipjar
+
+
+async def get_charge_details(tipjar_id):
+ """Return the default details for a satspay charge"""
+ tipjar = await get_tipjar(tipjar_id)
+ wallet_id = tipjar.wallet
+ wallet = await get_wallet(wallet_id)
+ user = wallet.user
+ details = {
+ "time": 1440,
+ "user": user,
+ "lnbitswallet": wallet_id,
+ "onchainwallet": tipjar.onchain,
+ "completelink": "/tipjar/" + str(tipjar_id),
+ "completelinktext": "Thanks for the tip!",
+ }
+ return details
diff --git a/lnbits/extensions/tipjar/migrations.py b/lnbits/extensions/tipjar/migrations.py
new file mode 100644
index 00000000..6b58fbca
--- /dev/null
+++ b/lnbits/extensions/tipjar/migrations.py
@@ -0,0 +1,27 @@
+async def m001_initial(db):
+
+ await db.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS tipjar.TipJars (
+ id {db.serial_primary_key},
+ name TEXT NOT NULL,
+ wallet TEXT NOT NULL,
+ onchain TEXT,
+ webhook TEXT
+ );
+ """
+ )
+
+ await db.execute(
+ f"""
+ CREATE TABLE IF NOT EXISTS tipjar.Tips (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ name TEXT NOT NULL,
+ message TEXT NOT NULL,
+ sats INT NOT NULL,
+ tipjar INT NOT NULL,
+ FOREIGN KEY(tipjar) REFERENCES {db.references_schema}TipJars(id)
+ );
+ """
+ )
diff --git a/lnbits/extensions/tipjar/models.py b/lnbits/extensions/tipjar/models.py
new file mode 100644
index 00000000..92f25ab3
--- /dev/null
+++ b/lnbits/extensions/tipjar/models.py
@@ -0,0 +1,69 @@
+from sqlite3 import Row
+from typing import Optional
+
+from fastapi.param_functions import Query
+from pydantic import BaseModel
+from pydantic.main import BaseModel
+
+
+class CreateCharge(BaseModel):
+ onchainwallet: str = Query(None)
+ lnbitswallet: str = Query(None)
+ description: str = Query(...)
+ webhook: str = Query(None)
+ completelink: str = Query(None)
+ completelinktext: str = Query(None)
+ time: int = Query(..., ge=1)
+ amount: int = Query(..., ge=1)
+
+
+class createTip(BaseModel):
+ id: str
+ wallet: str
+ sats: int
+ tipjar: int
+ name: str = "Anonymous"
+ message: str = ""
+
+
+class Tip(BaseModel):
+ """A Tip represents a single donation"""
+
+ id: str # This ID always corresponds to a satspay charge ID
+ wallet: str
+ name: str # Name of the donor
+ message: str # Donation message
+ sats: int
+ tipjar: int # The ID of the corresponding tip jar
+
+ @classmethod
+ def from_row(cls, row: Row) -> "Tip":
+ return cls(**dict(row))
+
+
+class createTipJar(BaseModel):
+ name: str
+ wallet: str
+ webhook: str = None
+ onchain: str = None
+
+
+class createTips(BaseModel):
+ name: str
+ sats: str
+ tipjar: str
+ message: str
+
+
+class TipJar(BaseModel):
+ """A TipJar represents a user's tip jar"""
+
+ id: int
+ name: str # The name of the donatee
+ wallet: str # Lightning wallet
+ onchain: Optional[str] # Watchonly wallet
+ webhook: Optional[str] # URL to POST tips to
+
+ @classmethod
+ def from_row(cls, row: Row) -> "TipJar":
+ return cls(**dict(row))
diff --git a/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html
new file mode 100644
index 00000000..42788bad
--- /dev/null
+++ b/lnbits/extensions/tipjar/templates/tipjar/_api_docs.html
@@ -0,0 +1,16 @@
+
+
+
+ Tip Jar: Receive tips with messages!
+
+
+ Your personal Bitcoin tip page, which supports
+ lightning and on-chain payments.
+ Notifications, including a donation message,
+ can be sent via webhook.
+
+ Created by, Fitti
+
+
+
diff --git a/lnbits/extensions/tipjar/templates/tipjar/display.html b/lnbits/extensions/tipjar/templates/tipjar/display.html
new file mode 100644
index 00000000..80e5c6fe
--- /dev/null
+++ b/lnbits/extensions/tipjar/templates/tipjar/display.html
@@ -0,0 +1,94 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+ Tip {{ donatee }} some sats!
+
+
+
+
+
+
+ Submit
+
+
+
+
+
+
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
diff --git a/lnbits/extensions/tipjar/templates/tipjar/index.html b/lnbits/extensions/tipjar/templates/tipjar/index.html
new file mode 100644
index 00000000..dda49842
--- /dev/null
+++ b/lnbits/extensions/tipjar/templates/tipjar/index.html
@@ -0,0 +1,447 @@
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
+%} {% block page %}
+
+
+
+
+ New TipJar
+
+
+
+
+
+
+
+ TipJars
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+ Tips
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
+
+
+ {{ col.label }}
+
+
+
+
+
+
+ {{ col.value }}
+
+
+
+
+
+
+ {% endraw %}
+
+
+
+
+
+
+
+
+ {{SITE_TITLE}} TipJar extension
+
+
+
+
+ {% include "tipjar/_api_docs.html" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Watch-Only extension MUST be activated and have a wallet
+
+
+
+
+
+
+
+
+
+
+
+ Update TipJar
+
+ Create TipJar
+ Cancel
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/extensions/tipjar/views.py b/lnbits/extensions/tipjar/views.py
new file mode 100644
index 00000000..21a87246
--- /dev/null
+++ b/lnbits/extensions/tipjar/views.py
@@ -0,0 +1,37 @@
+from http import HTTPStatus
+
+from fastapi import Request
+from fastapi.param_functions import Query
+from fastapi.params import Depends
+from fastapi.templating import Jinja2Templates
+from starlette.exceptions import HTTPException
+
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import tipjar_ext, tipjar_renderer
+from .crud import get_tipjar
+
+templates = Jinja2Templates(directory="templates")
+
+
+@tipjar_ext.get("/")
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return tipjar_renderer().TemplateResponse(
+ "tipjar/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@tipjar_ext.get("/{tipjar_id}")
+async def tip(request: Request, tipjar_id: int = Query(None)):
+ """Return the donation form for the Tipjar corresponding to id"""
+ tipjar = await get_tipjar(tipjar_id)
+ if not tipjar:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TipJar does not exist."
+ )
+
+ return tipjar_renderer().TemplateResponse(
+ "tipjar/display.html",
+ {"request": request, "donatee": tipjar.name, "tipjar": tipjar.id},
+ )
diff --git a/lnbits/extensions/tipjar/views_api.py b/lnbits/extensions/tipjar/views_api.py
new file mode 100644
index 00000000..5a55a3ca
--- /dev/null
+++ b/lnbits/extensions/tipjar/views_api.py
@@ -0,0 +1,200 @@
+from http import HTTPStatus
+
+from fastapi.param_functions import Query
+from fastapi.params import Depends
+from starlette.exceptions import HTTPException
+
+from lnbits.core.crud import get_user
+from lnbits.decorators import WalletTypeInfo, get_key_type
+
+from ..satspay.crud import create_charge
+from . import tipjar_ext
+from .crud import (
+ create_tip,
+ create_tipjar,
+ delete_tip,
+ delete_tipjar,
+ get_tip,
+ get_tipjar,
+ get_tipjars,
+ get_tips,
+ update_tip,
+ update_tipjar,
+)
+from .helpers import get_charge_details
+from .models import CreateCharge, createTipJar, createTips
+
+
+@tipjar_ext.post("/api/v1/tipjars")
+async def api_create_tipjar(data: createTipJar):
+ """Create a tipjar, which holds data about how/where to post tips"""
+ try:
+ tipjar = await create_tipjar(data)
+ except Exception as e:
+ raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
+
+ return tipjar.dict()
+
+
+async def user_from_wallet(wallet: WalletTypeInfo = Depends(get_key_type)):
+ return wallet.wallet.user
+
+
+@tipjar_ext.post("/api/v1/tips")
+async def api_create_tip(data: createTips):
+ """Take data from tip form and return satspay charge"""
+ sats = data.sats
+ message = data.message
+ if not message:
+ message = "No message"
+ tipjar_id = data.tipjar
+ tipjar = await get_tipjar(tipjar_id)
+
+ webhook = tipjar.webhook
+ charge_details = await get_charge_details(tipjar.id)
+ name = data.name
+ # Ensure that description string can be split reliably
+ name = name.replace('"', "''")
+ if not name:
+ name = "Anonymous"
+ description = f'"{name}": {message}'
+ charge = await create_charge(
+ user=charge_details["user"],
+ data=CreateCharge(
+ amount=sats,
+ webhook=webhook,
+ description=description,
+ onchainwallet=charge_details["onchainwallet"],
+ lnbitswallet=charge_details["lnbitswallet"],
+ completelink=charge_details["completelink"],
+ completelinktext=charge_details["completelinktext"],
+ time=charge_details["time"],
+ ),
+ )
+
+ await create_tip(
+ id=charge.id,
+ wallet=tipjar.wallet,
+ message=message,
+ name=name,
+ sats=data.sats,
+ tipjar=data.tipjar,
+ )
+
+ return {"redirect_url": f"/satspay/{charge.id}"}
+
+
+@tipjar_ext.get("/api/v1/tipjars")
+async def api_get_tipjars(wallet: WalletTypeInfo = Depends(get_key_type)):
+ """Return list of all tipjars assigned to wallet with given invoice key"""
+ wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
+ tipjars = []
+ for wallet_id in wallet_ids:
+ new_tipjars = await get_tipjars(wallet_id)
+ tipjars += new_tipjars if new_tipjars else []
+ return [tipjar.dict() for tipjar in tipjars] if tipjars else []
+
+
+@tipjar_ext.get("/api/v1/tips")
+async def api_get_tips(wallet: WalletTypeInfo = Depends(get_key_type)):
+ """Return list of all tips assigned to wallet with given invoice key"""
+ wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
+ tips = []
+ for wallet_id in wallet_ids:
+ new_tips = await get_tips(wallet_id)
+ tips += new_tips if new_tips else []
+ return [tip.dict() for tip in tips] if tips else []
+
+
+@tipjar_ext.put("/api/v1/tips/{tip_id}")
+async def api_update_tip(
+ wallet: WalletTypeInfo = Depends(get_key_type), tip_id: str = Query(None)
+):
+ """Update a tip with the data given in the request"""
+ if tip_id:
+ tip = await get_tip(tip_id)
+
+ if not tip:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Tip does not exist."
+ )
+
+ if tip.wallet != wallet.wallet.id:
+
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your tip."
+ )
+
+ tip = await update_tip(tip_id, **g.data)
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="No tip ID specified"
+ )
+ return tip.dict()
+
+
+@tipjar_ext.put("/api/v1/tipjars/{tipjar_id}")
+async def api_update_tipjar(
+ wallet: WalletTypeInfo = Depends(get_key_type), tipjar_id: str = Query(None)
+):
+ """Update a tipjar with the data given in the request"""
+ if tipjar_id:
+ tipjar = await get_tipjar(tipjar_id)
+
+ if not tipjar:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TipJar does not exist."
+ )
+
+ if tipjar.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Not your tipjar."
+ )
+
+ tipjar = await update_tipjar(tipjar_id, **data)
+ else:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST, detail="No tipjar ID specified"
+ )
+ return tipjar.dict()
+
+
+@tipjar_ext.delete("/api/v1/tips/{tip_id}")
+async def api_delete_tip(
+ wallet: WalletTypeInfo = Depends(get_key_type), tip_id: str = Query(None)
+):
+ """Delete the tip with the given tip_id"""
+ tip = await get_tip(tip_id)
+ if not tip:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="No tip with this ID!"
+ )
+ if tip.wallet != wallet.wallet.id:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not authorized to delete this tip!",
+ )
+ await delete_tip(tip_id)
+
+ return "", HTTPStatus.NO_CONTENT
+
+
+@tipjar_ext.delete("/api/v1/tipjars/{tipjar_id}")
+async def api_delete_tipjar(
+ wallet: WalletTypeInfo = Depends(get_key_type), tipjar_id: str = Query(None)
+):
+ """Delete the tipjar with the given tipjar_id"""
+ tipjar = await get_tipjar(tipjar_id)
+ if not tipjar:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="No tipjar with this ID!"
+ )
+ if tipjar.wallet != wallet.wallet.id:
+
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Not authorized to delete this tipjar!",
+ )
+ await delete_tipjar(tipjar_id)
+
+ return "", HTTPStatus.NO_CONTENT
diff --git a/lnbits/extensions/tpos/__init__.py b/lnbits/extensions/tpos/__init__.py
index daa3022e..c62981d7 100644
--- a/lnbits/extensions/tpos/__init__.py
+++ b/lnbits/extensions/tpos/__init__.py
@@ -1,11 +1,15 @@
-from quart import Blueprint
+from fastapi import APIRouter
+
from lnbits.db import Database
+from lnbits.helpers import template_renderer
db = Database("ext_tpos")
-tpos_ext: Blueprint = Blueprint(
- "tpos", __name__, static_folder="static", template_folder="templates"
-)
+tpos_ext: APIRouter = APIRouter(prefix="/tpos", tags=["TPoS"])
+
+
+def tpos_renderer():
+ return template_renderer(["lnbits/extensions/tpos/templates"])
from .views_api import * # noqa
diff --git a/lnbits/extensions/tpos/crud.py b/lnbits/extensions/tpos/crud.py
index afd4f973..1a198769 100644
--- a/lnbits/extensions/tpos/crud.py
+++ b/lnbits/extensions/tpos/crud.py
@@ -3,17 +3,17 @@ from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
-from .models import TPoS
+from .models import CreateTposData, TPoS
-async def create_tpos(*, wallet_id: str, name: str, currency: str) -> TPoS:
+async def create_tpos(wallet_id: str, data: CreateTposData) -> TPoS:
tpos_id = urlsafe_short_hash()
await db.execute(
"""
- INSERT INTO tposs (id, wallet, name, currency)
+ INSERT INTO tpos.tposs (id, wallet, name, currency)
VALUES (?, ?, ?, ?)
""",
- (tpos_id, wallet_id, name, currency),
+ (tpos_id, wallet_id, data.name, data.currency),
)
tpos = await get_tpos(tpos_id)
@@ -22,7 +22,7 @@ async def create_tpos(*, wallet_id: str, name: str, currency: str) -> TPoS:
async def get_tpos(tpos_id: str) -> Optional[TPoS]:
- row = await db.fetchone("SELECT * FROM tposs WHERE id = ?", (tpos_id,))
+ row = await db.fetchone("SELECT * FROM tpos.tposs WHERE id = ?", (tpos_id,))
return TPoS.from_row(row) if row else None
@@ -32,11 +32,11 @@ async def get_tposs(wallet_ids: Union[str, List[str]]) -> List[TPoS]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
- f"SELECT * FROM tposs WHERE wallet IN ({q})", (*wallet_ids,)
+ f"SELECT * FROM tpos.tposs WHERE wallet IN ({q})", (*wallet_ids,)
)
return [TPoS.from_row(row) for row in rows]
async def delete_tpos(tpos_id: str) -> None:
- await db.execute("DELETE FROM tposs WHERE id = ?", (tpos_id,))
+ await db.execute("DELETE FROM tpos.tposs WHERE id = ?", (tpos_id,))
diff --git a/lnbits/extensions/tpos/migrations.py b/lnbits/extensions/tpos/migrations.py
index 243ebe0b..7a7fff0d 100644
--- a/lnbits/extensions/tpos/migrations.py
+++ b/lnbits/extensions/tpos/migrations.py
@@ -4,7 +4,7 @@ async def m001_initial(db):
"""
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS tposs (
+ CREATE TABLE tpos.tposs (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
diff --git a/lnbits/extensions/tpos/models.py b/lnbits/extensions/tpos/models.py
index e1061567..653a055c 100644
--- a/lnbits/extensions/tpos/models.py
+++ b/lnbits/extensions/tpos/models.py
@@ -1,8 +1,14 @@
from sqlite3 import Row
-from typing import NamedTuple
+
+from pydantic import BaseModel
-class TPoS(NamedTuple):
+class CreateTposData(BaseModel):
+ name: str
+ currency: str
+
+
+class TPoS(BaseModel):
id: str
wallet: str
name: str
diff --git a/lnbits/extensions/tpos/templates/tpos/_api_docs.html b/lnbits/extensions/tpos/templates/tpos/_api_docs.html
index 6ceab728..7897383d 100644
--- a/lnbits/extensions/tpos/templates/tpos/_api_docs.html
+++ b/lnbits/extensions/tpos/templates/tpos/_api_docs.html
@@ -17,7 +17,7 @@
[<tpos_object>, ...]
Curl example
curl -X GET {{ request.url_root }}api/v1/tposs -H "X-Api-Key:
+ >curl -X GET {{ request.base_url }}api/v1/tposs -H "X-Api-Key:
<invoice_key>"
@@ -42,7 +42,7 @@
>
Curl example
curl -X POST {{ request.url_root }}api/v1/tposs -d '{"name":
+ >curl -X POST {{ request.base_url }}api/v1/tposs -d '{"name":
<string>, "currency": <string>}' -H "Content-type:
application/json" -H "X-Api-Key: <admin_key>"
@@ -69,7 +69,7 @@
Curl example
curl -X DELETE {{ request.url_root }}api/v1/tposs/<tpos_id> -H
+ >curl -X DELETE {{ request.base_url }}api/v1/tposs/<tpos_id> -H
"X-Api-Key: <admin_key>"
diff --git a/lnbits/extensions/tpos/templates/tpos/index.html b/lnbits/extensions/tpos/templates/tpos/index.html
index 43edb618..a8971211 100644
--- a/lnbits/extensions/tpos/templates/tpos/index.html
+++ b/lnbits/extensions/tpos/templates/tpos/index.html
@@ -4,7 +4,7 @@
- New TPoS
@@ -77,7 +77,7 @@
- LNbits TPoS extension
+ {{SITE_TITLE}} TPoS extension
@@ -119,7 +119,7 @@
Create TPoS
- 1
- 2
- 3
C
- 4
- 5
- 6
- 7
- 8
- 9
OK
@@ -64,17 +122,27 @@
unelevated
@click="stack.splice(-1, 1)"
size="xl"
- color="grey-7"
+ :outline="!($q.dark.isActive)"
+ rounded
+ color="primary"
>DEL
- 0
#
@@ -133,6 +201,15 @@
+
+
+
+
{% endblock %} {% block styles %}
@@ -143,9 +220,11 @@
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
}
+
.keypad .btn {
height: 100%;
}
+
.btn-cancel,
.btn-confirm {
grid-row: auto/span 2;
@@ -172,6 +251,9 @@
},
urlDialog: {
show: false
+ },
+ complete: {
+ show: false
}
}
},
@@ -188,6 +270,7 @@
return Math.ceil((this.amount / this.exchangeRate) * 100000000)
},
fsat: function () {
+ console.log('sat', this.sat, LNbits.utils.formatSat(this.sat))
return LNbits.utils.formatSat(this.sat)
}
},
@@ -203,10 +286,12 @@
showInvoice: function () {
var self = this
var dialog = this.invoiceDialog
-
+ console.log(this.sat, this.tposId)
axios
- .post('/tpos/api/v1/tposs/' + this.tposId + '/invoices/', {
- amount: this.sat
+ .post('/tpos/api/v1/tposs/' + this.tposId + '/invoices', null, {
+ params: {
+ amount: this.sat
+ }
})
.then(function (response) {
dialog.data = response.data
@@ -231,14 +316,13 @@
dialog.dismissMsg()
dialog.show = false
- self.$q.notify({
- type: 'positive',
- message: self.fsat + ' sat received!',
- icon: null
- })
+ self.complete.show = true
+ setTimeout(function () {
+ self.complete.show = false
+ }, 5000)
}
})
- }, 2000)
+ }, 3000)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
diff --git a/lnbits/extensions/tpos/views.py b/lnbits/extensions/tpos/views.py
index ce842295..2d78ecce 100644
--- a/lnbits/extensions/tpos/views.py
+++ b/lnbits/extensions/tpos/views.py
@@ -1,23 +1,35 @@
-from quart import g, abort, render_template
from http import HTTPStatus
-from lnbits.decorators import check_user_exists, validate_uuids
+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 . import tpos_ext
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import tpos_ext, tpos_renderer
from .crud import get_tpos
-
-@tpos_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("tpos/index.html", user=g.user)
+templates = Jinja2Templates(directory="templates")
-@tpos_ext.route("/")
-async def tpos(tpos_id):
+@tpos_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return tpos_renderer().TemplateResponse(
+ "tpos/index.html", {"request": request, "user": user.dict()}
+ )
+
+
+@tpos_ext.get("/{tpos_id}")
+async def tpos(request: Request, tpos_id):
tpos = await get_tpos(tpos_id)
if not tpos:
- abort(HTTPStatus.NOT_FOUND, "TPoS does not exist.")
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
+ )
- return await render_template("tpos/tpos.html", tpos=tpos)
+ return tpos_renderer().TemplateResponse(
+ "tpos/tpos.html", {"request": request, "tpos": tpos}
+ )
diff --git a/lnbits/extensions/tpos/views_api.py b/lnbits/extensions/tpos/views_api.py
index 1f0802c7..ae457b61 100644
--- a/lnbits/extensions/tpos/views_api.py
+++ b/lnbits/extensions/tpos/views_api.py
@@ -1,101 +1,90 @@
-from quart import g, jsonify, request
from http import HTTPStatus
-from lnbits.core.crud import get_user, get_wallet
-from lnbits.core.services import create_invoice, check_invoice_status
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
+from fastapi import Query
+from fastapi.params import Depends
+from starlette.exceptions import HTTPException
+
+from lnbits.core.crud import get_user
+from lnbits.core.services import create_invoice
+from lnbits.core.views.api import api_payment
+from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import tpos_ext
-from .crud import create_tpos, get_tpos, get_tposs, delete_tpos
+from .crud import create_tpos, delete_tpos, get_tpos, get_tposs
+from .models import CreateTposData
-@tpos_ext.route("/api/v1/tposs", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_tposs():
- wallet_ids = [g.wallet.id]
- if "all_wallets" in request.args:
- wallet_ids = (await get_user(g.wallet.user)).wallet_ids
+@tpos_ext.get("/api/v1/tposs", status_code=HTTPStatus.OK)
+async def api_tposs(
+ all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ wallet_ids = [wallet.wallet.id]
+ if all_wallets:
+ wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
- return (
- jsonify([tpos._asdict() for tpos in await get_tposs(wallet_ids)]),
- HTTPStatus.OK,
- )
+ return [tpos.dict() for tpos in await get_tposs(wallet_ids)]
-@tpos_ext.route("/api/v1/tposs", methods=["POST"])
-@api_check_wallet_key("invoice")
-@api_validate_post_request(
- schema={
- "name": {"type": "string", "empty": False, "required": True},
- "currency": {"type": "string", "empty": False, "required": True},
- }
-)
-async def api_tpos_create():
- tpos = await create_tpos(wallet_id=g.wallet.id, **g.data)
- return jsonify(tpos._asdict()), HTTPStatus.CREATED
+@tpos_ext.post("/api/v1/tposs", status_code=HTTPStatus.CREATED)
+async def api_tpos_create(
+ data: CreateTposData, wallet: WalletTypeInfo = Depends(get_key_type)
+):
+ tpos = await create_tpos(wallet_id=wallet.wallet.id, data=data)
+ return tpos.dict()
-@tpos_ext.route("/api/v1/tposs/", methods=["DELETE"])
-@api_check_wallet_key("admin")
-async def api_tpos_delete(tpos_id):
+@tpos_ext.delete("/api/v1/tposs/{tpos_id}")
+async def api_tpos_delete(
+ tpos_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
+):
tpos = await get_tpos(tpos_id)
if not tpos:
- return jsonify({"message": "TPoS does not exist."}), HTTPStatus.NOT_FOUND
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
+ )
- if tpos.wallet != g.wallet.id:
- return jsonify({"message": "Not your TPoS."}), HTTPStatus.FORBIDDEN
+ if tpos.wallet != wallet.wallet.id:
+ raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your TPoS.")
await delete_tpos(tpos_id)
-
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-@tpos_ext.route("/api/v1/tposs//invoices/", methods=["POST"])
-@api_validate_post_request(
- schema={"amount": {"type": "integer", "min": 1, "required": True}}
-)
-async def api_tpos_create_invoice(tpos_id):
+@tpos_ext.post("/api/v1/tposs/{tpos_id}/invoices", status_code=HTTPStatus.CREATED)
+async def api_tpos_create_invoice(amount: int = Query(..., ge=1), tpos_id: str = None):
tpos = await get_tpos(tpos_id)
if not tpos:
- return jsonify({"message": "TPoS does not exist."}), HTTPStatus.NOT_FOUND
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
+ )
try:
payment_hash, payment_request = await create_invoice(
wallet_id=tpos.wallet,
- amount=g.data["amount"],
+ amount=amount,
memo=f"{tpos.name}",
extra={"tag": "tpos"},
)
except Exception as e:
- return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR
+ raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
- return (
- jsonify({"payment_hash": payment_hash, "payment_request": payment_request}),
- HTTPStatus.CREATED,
- )
+ return {"payment_hash": payment_hash, "payment_request": payment_request}
-@tpos_ext.route("/api/v1/tposs//invoices/", methods=["GET"])
-async def api_tpos_check_invoice(tpos_id, payment_hash):
+@tpos_ext.get(
+ "/api/v1/tposs/{tpos_id}/invoices/{payment_hash}", status_code=HTTPStatus.OK
+)
+async def api_tpos_check_invoice(tpos_id: str, payment_hash: str):
tpos = await get_tpos(tpos_id)
-
if not tpos:
- return jsonify({"message": "TPoS does not exist."}), HTTPStatus.NOT_FOUND
-
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
+ )
try:
- status = await check_invoice_status(tpos.wallet, payment_hash)
- is_paid = not status.pending
+ status = await api_payment(payment_hash)
except Exception as exc:
print(exc)
- return jsonify({"paid": False}), HTTPStatus.OK
-
- if is_paid:
- wallet = await get_wallet(tpos.wallet)
- payment = await wallet.get_payment(payment_hash)
- await payment.set_pending(False)
-
- return jsonify({"paid": True}), HTTPStatus.OK
-
- return jsonify({"paid": False}), HTTPStatus.OK
+ return {"paid": False}
+ return status
diff --git a/lnbits/extensions/usermanager/__init__.py b/lnbits/extensions/usermanager/__init__.py
index 53154812..8ea02f6f 100644
--- a/lnbits/extensions/usermanager/__init__.py
+++ b/lnbits/extensions/usermanager/__init__.py
@@ -1,12 +1,16 @@
-from quart import Blueprint
+from fastapi import APIRouter
+
from lnbits.db import Database
+from lnbits.helpers import template_renderer
db = Database("ext_usermanager")
-usermanager_ext: Blueprint = Blueprint(
- "usermanager", __name__, static_folder="static", template_folder="templates"
-)
+usermanager_ext: APIRouter = APIRouter(prefix="/usermanager", tags=["usermanager"])
+
+
+def usermanager_renderer():
+ return template_renderer(["lnbits/extensions/usermanager/templates"])
-from .views_api import * # noqa
from .views import * # noqa
+from .views_api import * # noqa
diff --git a/lnbits/extensions/usermanager/crud.py b/lnbits/extensions/usermanager/crud.py
index dbee287c..1ce66d4f 100644
--- a/lnbits/extensions/usermanager/crud.py
+++ b/lnbits/extensions/usermanager/crud.py
@@ -1,48 +1,48 @@
-from typing import Optional, List
+from typing import List, Optional
-from lnbits.core.models import Payment
from lnbits.core.crud import (
create_account,
- get_user,
- get_payments,
create_wallet,
delete_wallet,
+ get_payments,
+ get_user,
)
+from lnbits.core.models import Payment
from . import db
-from .models import Users, Wallets
-
+from .models import CreateUserData, Users, Wallets
### Users
-async def create_usermanager_user(
- user_name: str,
- wallet_name: str,
- admin_id: str,
- email: Optional[str] = None,
- password: Optional[str] = None,
-) -> Users:
+async def create_usermanager_user(data: CreateUserData) -> Users:
account = await create_account()
user = await get_user(account.id)
assert user, "Newly created user couldn't be retrieved"
- wallet = await create_wallet(user_id=user.id, wallet_name=wallet_name)
+ wallet = await create_wallet(user_id=user.id, wallet_name=data.wallet_name)
await db.execute(
"""
- INSERT INTO users (id, name, admin, email, password)
+ INSERT INTO usermanager.users (id, name, admin, email, password)
VALUES (?, ?, ?, ?, ?)
""",
- (user.id, user_name, admin_id, email, password),
+ (user.id, data.user_name, data.admin_id, data.email, data.password),
)
await db.execute(
"""
- INSERT INTO wallets (id, admin, name, user, adminkey, inkey)
+ INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey)
VALUES (?, ?, ?, ?, ?, ?)
""",
- (wallet.id, admin_id, wallet_name, user.id, wallet.adminkey, wallet.inkey),
+ (
+ wallet.id,
+ data.admin_id,
+ data.wallet_name,
+ user.id,
+ wallet.adminkey,
+ wallet.inkey,
+ ),
)
user_created = await get_usermanager_user(user.id)
@@ -51,12 +51,15 @@ async def create_usermanager_user(
async def get_usermanager_user(user_id: str) -> Optional[Users]:
- row = await db.fetchone("SELECT * FROM users WHERE id = ?", (user_id,))
+ row = await db.fetchone("SELECT * FROM usermanager.users WHERE id = ?", (user_id,))
return Users(**row) if row else None
async def get_usermanager_users(user_id: str) -> List[Users]:
- rows = await db.fetchall("SELECT * FROM users WHERE admin = ?", (user_id,))
+ rows = await db.fetchall(
+ "SELECT * FROM usermanager.users WHERE admin = ?", (user_id,)
+ )
+
return [Users(**row) for row in rows]
@@ -65,8 +68,8 @@ async def delete_usermanager_user(user_id: str) -> None:
for wallet in wallets:
await delete_wallet(user_id=user_id, wallet_id=wallet.id)
- await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
- await db.execute("DELETE FROM wallets WHERE user = ?", (user_id,))
+ await db.execute("DELETE FROM usermanager.users WHERE id = ?", (user_id,))
+ await db.execute("""DELETE FROM usermanager.wallets WHERE "user" = ?""", (user_id,))
### Wallets
@@ -78,7 +81,7 @@ async def create_usermanager_wallet(
wallet = await create_wallet(user_id=user_id, wallet_name=wallet_name)
await db.execute(
"""
- INSERT INTO wallets (id, admin, name, user, adminkey, inkey)
+ INSERT INTO usermanager.wallets (id, admin, name, "user", adminkey, inkey)
VALUES (?, ?, ?, ?, ?, ?)
""",
(wallet.id, admin_id, wallet_name, user_id, wallet.adminkey, wallet.inkey),
@@ -89,17 +92,23 @@ async def create_usermanager_wallet(
async def get_usermanager_wallet(wallet_id: str) -> Optional[Wallets]:
- row = await db.fetchone("SELECT * FROM wallets WHERE id = ?", (wallet_id,))
+ row = await db.fetchone(
+ "SELECT * FROM usermanager.wallets WHERE id = ?", (wallet_id,)
+ )
return Wallets(**row) if row else None
async def get_usermanager_wallets(admin_id: str) -> Optional[Wallets]:
- rows = await db.fetchall("SELECT * FROM wallets WHERE admin = ?", (admin_id,))
+ rows = await db.fetchall(
+ "SELECT * FROM usermanager.wallets WHERE admin = ?", (admin_id,)
+ )
return [Wallets(**row) for row in rows]
async def get_usermanager_users_wallets(user_id: str) -> Optional[Wallets]:
- rows = await db.fetchall("SELECT * FROM wallets WHERE user = ?", (user_id,))
+ rows = await db.fetchall(
+ """SELECT * FROM usermanager.wallets WHERE "user" = ?""", (user_id,)
+ )
return [Wallets(**row) for row in rows]
@@ -111,4 +120,4 @@ async def get_usermanager_wallet_transactions(wallet_id: str) -> Optional[Paymen
async def delete_usermanager_wallet(wallet_id: str, user_id: str) -> None:
await delete_wallet(user_id=user_id, wallet_id=wallet_id)
- await db.execute("DELETE FROM wallets WHERE id = ?", (wallet_id,))
+ await db.execute("DELETE FROM usermanager.wallets WHERE id = ?", (wallet_id,))
diff --git a/lnbits/extensions/usermanager/migrations.py b/lnbits/extensions/usermanager/migrations.py
index 9b60fa66..62a21575 100644
--- a/lnbits/extensions/usermanager/migrations.py
+++ b/lnbits/extensions/usermanager/migrations.py
@@ -4,7 +4,7 @@ async def m001_initial(db):
"""
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS users (
+ CREATE TABLE usermanager.users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
admin TEXT NOT NULL,
@@ -19,11 +19,11 @@ async def m001_initial(db):
"""
await db.execute(
"""
- CREATE TABLE IF NOT EXISTS wallets (
+ CREATE TABLE usermanager.wallets (
id TEXT PRIMARY KEY,
admin TEXT NOT NULL,
name TEXT NOT NULL,
- user TEXT NOT NULL,
+ "user" TEXT NOT NULL,
adminkey TEXT NOT NULL,
inkey TEXT NOT NULL
);
diff --git a/lnbits/extensions/usermanager/models.py b/lnbits/extensions/usermanager/models.py
index 97eaaea8..67facec6 100644
--- a/lnbits/extensions/usermanager/models.py
+++ b/lnbits/extensions/usermanager/models.py
@@ -1,16 +1,33 @@
-from typing import NamedTuple
from sqlite3 import Row
+from fastapi.param_functions import Query
+from pydantic import BaseModel
+from typing import Optional
-class Users(NamedTuple):
+
+class CreateUserData(BaseModel):
+ user_name: str = Query(...)
+ wallet_name: str = Query(...)
+ admin_id: str = Query(...)
+ email: str = Query("")
+ password: str = Query("")
+
+
+class CreateUserWallet(BaseModel):
+ user_id: str = Query(...)
+ wallet_name: str = Query(...)
+ admin_id: str = Query(...)
+
+
+class Users(BaseModel):
id: str
name: str
admin: str
- email: str
- password: str
+ email: Optional[str] = None
+ password: Optional[str] = None
-class Wallets(NamedTuple):
+class Wallets(BaseModel):
id: str
admin: str
name: str
diff --git a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html
index 2f516b28..f3b1e8bd 100644
--- a/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html
+++ b/lnbits/extensions/usermanager/templates/usermanager/_api_docs.html
@@ -42,8 +42,8 @@
JSON list of users
Curl example
curl -X GET {{ request.url_root }}api/v1/users -H "X-Api-Key: {{
- g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url }}usermanager/api/v1/users -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
@@ -62,8 +62,9 @@
JSON list of users
Curl example
curl -X GET {{ request.url_root }}api/v1/users/<user_id> -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url
+ }}usermanager/api/v1/users/<user_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -84,8 +85,9 @@
JSON wallet data
Curl example
curl -X GET {{ request.url_root }}api/v1/wallets/<user_id> -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url
+ }}usermanager/api/v1/wallets/<user_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -106,8 +108,9 @@
JSON a wallets transactions
Curl example
curl -X GET {{ request.url_root }}api/v1/wallets<wallet_id> -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X GET {{ request.base_url
+ }}usermanager/api/v1/wallets<wallet_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -147,11 +150,11 @@
>
Curl example
curl -X POST {{ request.url_root }}api/v1/users -d '{"admin_id": "{{
- g.user.id }}", "wallet_name": <string>, "user_name":
- <string>, "email": <Optional string>, "password": <
- Optional string>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
- "Content-type: application/json"
+ >curl -X POST {{ request.base_url }}usermanager/api/v1/users -d
+ '{"admin_id": "{{ user.id }}", "wallet_name": <string>,
+ "user_name": <string>, "email": <Optional string>,
+ "password": < Optional string>}' -H "X-Api-Key: {{
+ user.wallets[0].inkey }}" -H "Content-type: application/json"
@@ -185,10 +188,10 @@
>
Curl example
curl -X POST {{ request.url_root }}api/v1/wallets -d '{"user_id":
- <string>, "wallet_name": <string>, "admin_id": "{{
- g.user.id }}"}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
- "Content-type: application/json"
+ >curl -X POST {{ request.base_url }}usermanager/api/v1/wallets -d
+ '{"user_id": <string>, "wallet_name": <string>,
+ "admin_id": "{{ user.id }}"}' -H "X-Api-Key: {{ user.wallets[0].inkey
+ }}" -H "Content-type: application/json"
@@ -209,8 +212,9 @@
{"X-Api-Key": <string>}
Curl example
curl -X DELETE {{ request.url_root }}api/v1/users/<user_id> -H
- "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X DELETE {{ request.base_url
+ }}usermanager/api/v1/users/<user_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -226,8 +230,9 @@
{"X-Api-Key": <string>}
Curl example
curl -X DELETE {{ request.url_root }}api/v1/wallets/<wallet_id>
- -H "X-Api-Key: {{ g.user.wallets[0].inkey }}"
+ >curl -X DELETE {{ request.base_url
+ }}usermanager/api/v1/wallets/<wallet_id> -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
@@ -248,9 +253,9 @@
{"X-Api-Key": <string>}
Curl example
curl -X POST {{ request.url_root }}api/v1/extensions -d '{"userid":
- <string>, "extension": <string>, "active":
- <integer>}' -H "X-Api-Key: {{ g.user.wallets[0].inkey }}" -H
+ >curl -X POST {{ request.base_url }}usermanager/api/v1/extensions -d
+ '{"userid": <string>, "extension": <string>, "active":
+ <integer>}' -H "X-Api-Key: {{ user.wallets[0].inkey }}" -H
"Content-type: application/json"
diff --git a/lnbits/extensions/usermanager/templates/usermanager/index.html b/lnbits/extensions/usermanager/templates/usermanager/index.html
index 06e5423a..6fbe9686 100644
--- a/lnbits/extensions/usermanager/templates/usermanager/index.html
+++ b/lnbits/extensions/usermanager/templates/usermanager/index.html
@@ -4,10 +4,10 @@
- New User
- New Wallet
@@ -133,7 +133,9 @@
- LNbits User Manager Extension
+
+ {{SITE_TITLE}} User Manager Extension
+
@@ -172,7 +174,7 @@
Create User
Create Wallet
{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
\ No newline at end of file
diff --git a/lnbits/extensions/withdraw/templates/withdraw/display.html b/lnbits/extensions/withdraw/templates/withdraw/display.html
index 1368c352..5552c77f 100644
--- a/lnbits/extensions/withdraw/templates/withdraw/display.html
+++ b/lnbits/extensions/withdraw/templates/withdraw/display.html
@@ -7,10 +7,10 @@
{% if link.is_spent %}
Withdraw is spent.
{% endif %}
-
+
@@ -18,7 +18,7 @@
- Copy LNURL
diff --git a/lnbits/extensions/withdraw/templates/withdraw/index.html b/lnbits/extensions/withdraw/templates/withdraw/index.html
index 7442ca96..13673028 100644
--- a/lnbits/extensions/withdraw/templates/withdraw/index.html
+++ b/lnbits/extensions/withdraw/templates/withdraw/index.html
@@ -1,43 +1,28 @@
-{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
-%} {% block scripts %} {{ window_vars(user) }}
+{% extends "base.html" %} {% from "macros.jinja" import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
{% endblock %} {% block page %}
-
-
-
- Quick vouchers
- Advanced withdraw link(s)
-
-
+
+
+
+ Quick vouchers
+ Advanced withdraw link(s)
+
+
-
-
-
-
- Withdraw links
-
-
- Export to CSV
-
-
-
- {% raw %}
-
+
+
+
+
+ Withdraw links
+
+
+ Export to CSV
+
+
+
+ {% raw %}
+
@@ -46,7 +31,7 @@
-
+
embeddable image
+ csv list
-
- {% endraw %}
-
-
-
-
+ {% endraw %}
+
+
+
+
-
-
-
-
- LNbits LNURL-withdraw extension
-
-
-
-
-
- {% include "withdraw/_api_docs.html" %}
-
- {% include "withdraw/_lnurl.html" %}
-
-
-
-
+
+
+
+
+ {{SITE_TITLE}} LNURL-withdraw extension
+
+
+
+
+
+ {% include "withdraw/_api_docs.html" %}
+
+ {% include "withdraw/_lnurl.html" %}
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Use unique withdraw QR codes to reduce
- `assmilking`
- This is recommended if you are sharing the links on social
- media or print QR codes.
-
-
-
-
- Update withdraw link
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Use unique withdraw QR codes to reduce `assmilking`
+
+ This is recommended if you are sharing the links on social media or print QR codes.
+
+
+
+
+ Update withdraw link
+ Create withdraw link
- Cancel
-
-
-
-
+ formDialog.data.wait_time == null" type="submit">Create withdraw link
+ Cancel
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
+ Create vouchers
- Cancel
-
-
-
-
+ simpleformDialog.data.uses == null" type="submit">Create vouchers
+ Cancel
+
+
+
+
-
-
-
-
- {% raw %}
-
-
- ID: {{ qrCodeDialog.data.id }}
- Unique: {{ qrCodeDialog.data.is_unique }}
+
+
+
+
+ {% raw %}
+
+
+ ID: {{ qrCodeDialog.data.id }}
+ Unique: {{ qrCodeDialog.data.is_unique }}
(QR code will change after each withdrawal)
Max. withdrawable: {{
@@ -324,7 +213,7 @@
qrCodeDialog.data.uses }}
@@ -356,4 +245,4 @@
-{% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/lnbits/extensions/withdraw/views.py b/lnbits/extensions/withdraw/views.py
index 28f25756..81aeef98 100644
--- a/lnbits/extensions/withdraw/views.py
+++ b/lnbits/extensions/withdraw/views.py
@@ -1,40 +1,66 @@
-from quart import g, abort, render_template
from http import HTTPStatus
-import pyqrcode
from io import BytesIO
-from lnbits.decorators import check_user_exists, validate_uuids
-from . import withdraw_ext
-from .crud import get_withdraw_link, chunks
+import pyqrcode
+from fastapi import Request
+from fastapi.params import Depends
+from fastapi.templating import Jinja2Templates
+from starlette.exceptions import HTTPException
+from starlette.responses import HTMLResponse, StreamingResponse
+
+from lnbits.core.models import User
+from lnbits.decorators import check_user_exists
+
+from . import withdraw_ext, withdraw_renderer
+from .crud import chunks, get_withdraw_link
+
+templates = Jinja2Templates(directory="templates")
-@withdraw_ext.route("/")
-@validate_uuids(["usr"], required=True)
-@check_user_exists()
-async def index():
- return await render_template("withdraw/index.html", user=g.user)
-
-
-@withdraw_ext.route("/")
-async def display(link_id):
- link = await get_withdraw_link(link_id, 0) or abort(
- HTTPStatus.NOT_FOUND, "Withdraw link does not exist."
+@withdraw_ext.get("/", response_class=HTMLResponse)
+async def index(request: Request, user: User = Depends(check_user_exists)):
+ return withdraw_renderer().TemplateResponse(
+ "withdraw/index.html", {"request": request, "user": user.dict()}
)
- return await render_template("withdraw/display.html", link=link, unique=True)
-@withdraw_ext.route("/img/")
-async def img(link_id):
- link = await get_withdraw_link(link_id, 0) or abort(
- HTTPStatus.NOT_FOUND, "Withdraw link does not exist."
+@withdraw_ext.get("/{link_id}", response_class=HTMLResponse)
+async def display(request: Request, link_id):
+ link = await get_withdraw_link(link_id, 0)
+
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
+ )
+ return withdraw_renderer().TemplateResponse(
+ "withdraw/display.html",
+ {
+ "request": request,
+ "link": link.dict(),
+ "lnurl": link.lnurl(req=request),
+ "unique": True,
+ },
)
- qr = pyqrcode.create(link.lnurl)
+
+
+@withdraw_ext.get("/img/{link_id}", response_class=StreamingResponse)
+async def img(request: Request, link_id):
+ link = await get_withdraw_link(link_id, 0)
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
+ )
+ qr = pyqrcode.create(link.lnurl(request))
stream = BytesIO()
qr.svg(stream, scale=3)
- return (
- stream.getvalue(),
- 200,
- {
+ stream.seek(0)
+
+ async def _generator(stream: BytesIO):
+ yield stream.getvalue()
+
+ return StreamingResponse(
+ _generator(stream),
+ headers={
"Content-Type": "image/svg+xml",
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
@@ -43,21 +69,70 @@ async def img(link_id):
)
-@withdraw_ext.route("/print/")
-async def print_qr(link_id):
- link = await get_withdraw_link(link_id) or abort(
- HTTPStatus.NOT_FOUND, "Withdraw link does not exist."
- )
+@withdraw_ext.get("/print/{link_id}", response_class=HTMLResponse)
+async def print_qr(request: Request, link_id):
+ link = await get_withdraw_link(link_id)
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
+ )
+ # response.status_code = HTTPStatus.NOT_FOUND
+ # return "Withdraw link does not exist."
+
if link.uses == 0:
- return await render_template("withdraw/print_qr.html", link=link, unique=False)
+
+ return withdraw_renderer().TemplateResponse(
+ "withdraw/print_qr.html",
+ {"request": request, "link": link.dict(), "unique": False},
+ )
links = []
count = 0
+
for x in link.usescsv.split(","):
- linkk = await get_withdraw_link(link_id, count) or abort(
- HTTPStatus.NOT_FOUND, "Withdraw link does not exist."
- )
- links.append(str(linkk.lnurl))
+ linkk = await get_withdraw_link(link_id, count)
+ if not linkk:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
+ )
+ links.append(str(linkk.lnurl(request)))
count = count + 1
page_link = list(chunks(links, 2))
linked = list(chunks(page_link, 5))
- return await render_template("withdraw/print_qr.html", link=linked, unique=True)
+
+ return withdraw_renderer().TemplateResponse(
+ "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True}
+ )
+
+@withdraw_ext.get("/csv/{link_id}", response_class=HTMLResponse)
+async def print_qr(request: Request, link_id):
+ link = await get_withdraw_link(link_id)
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
+ )
+ # response.status_code = HTTPStatus.NOT_FOUND
+ # return "Withdraw link does not exist."
+
+ if link.uses == 0:
+
+ return withdraw_renderer().TemplateResponse(
+ "withdraw/csv.html",
+ {"request": request, "link": link.dict(), "unique": False},
+ )
+ links = []
+ count = 0
+
+ for x in link.usescsv.split(","):
+ linkk = await get_withdraw_link(link_id, count)
+ if not linkk:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
+ )
+ links.append(str(linkk.lnurl(request)))
+ count = count + 1
+ page_link = list(chunks(links, 2))
+ linked = list(chunks(page_link, 5))
+
+ return withdraw_renderer().TemplateResponse(
+ "withdraw/csv.html", {"request": request, "link": linked, "unique": True}
+ )
\ No newline at end of file
diff --git a/lnbits/extensions/withdraw/views_api.py b/lnbits/extensions/withdraw/views_api.py
index 4979b932..8dd9e340 100644
--- a/lnbits/extensions/withdraw/views_api.py
+++ b/lnbits/extensions/withdraw/views_api.py
@@ -1,97 +1,90 @@
-from quart import g, jsonify, request
from http import HTTPStatus
+
+from fastapi.param_functions import Query
+from fastapi.params import Depends
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore
+from starlette.exceptions import HTTPException
+from starlette.requests import Request
from lnbits.core.crud import get_user
-from lnbits.decorators import api_check_wallet_key, api_validate_post_request
+from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import withdraw_ext
from .crud import (
create_withdraw_link,
+ delete_withdraw_link,
+ get_hash_check,
get_withdraw_link,
get_withdraw_links,
update_withdraw_link,
- delete_withdraw_link,
- create_hash_check,
- get_hash_check,
)
+from .models import CreateWithdrawData
-@withdraw_ext.route("/api/v1/links", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_links():
- wallet_ids = [g.wallet.id]
+@withdraw_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
- if "all_wallets" in request.args:
- wallet_ids = (await get_user(g.wallet.user)).wallet_ids
try:
- return (
- jsonify(
- [
- {
- **link._asdict(),
- **{"lnurl": link.lnurl},
- }
- for link in await get_withdraw_links(wallet_ids)
- ]
- ),
- HTTPStatus.OK,
- )
+ return [
+ {**link.dict(), **{"lnurl": link.lnurl(req)}}
+ for link in await get_withdraw_links(wallet_ids)
+ ]
+
except LnurlInvalidUrl:
- return (
- jsonify(
- {
- "message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."
- }
- ),
- HTTPStatus.UPGRADE_REQUIRED,
+ raise HTTPException(
+ status_code=HTTPStatus.UPGRADE_REQUIRED,
+ detail="LNURLs need to be delivered over a publically accessible `https` domain or Tor.",
)
-@withdraw_ext.route("/api/v1/links/", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_link_retrieve(link_id):
+@withdraw_ext.get("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
+async def api_link_retrieve(
+ link_id, request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
+):
link = await get_withdraw_link(link_id, 0)
if not link:
- return (
- jsonify({"message": "Withdraw link does not exist."}),
- HTTPStatus.NOT_FOUND,
+ raise HTTPException(
+ detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
- if link.wallet != g.wallet.id:
- return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN
-
- return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK
+ if link.wallet != wallet.wallet.id:
+ raise HTTPException(
+ detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
+ )
+ return {**link, **{"lnurl": link.lnurl(request)}}
-@withdraw_ext.route("/api/v1/links", methods=["POST"])
-@withdraw_ext.route("/api/v1/links/", methods=["PUT"])
-@api_check_wallet_key("admin")
-@api_validate_post_request(
- schema={
- "title": {"type": "string", "empty": False, "required": True},
- "min_withdrawable": {"type": "integer", "min": 1, "required": True},
- "max_withdrawable": {"type": "integer", "min": 1, "required": True},
- "uses": {"type": "integer", "min": 1, "required": True},
- "wait_time": {"type": "integer", "min": 1, "required": True},
- "is_unique": {"type": "boolean", "required": True},
- }
-)
-async def api_link_create_or_update(link_id=None):
- if g.data["max_withdrawable"] < g.data["min_withdrawable"]:
- return (
- jsonify(
- {
- "message": "`max_withdrawable` needs to be at least `min_withdrawable`."
- }
- ),
- HTTPStatus.BAD_REQUEST,
+@withdraw_ext.post("/api/v1/links", status_code=HTTPStatus.CREATED)
+@withdraw_ext.put("/api/v1/links/{link_id}", status_code=HTTPStatus.OK)
+async def api_link_create_or_update(
+ req: Request,
+ data: CreateWithdrawData,
+ link_id: str = None,
+ wallet: WalletTypeInfo = Depends(require_admin_key),
+):
+ if data.min_withdrawable < 1:
+ raise HTTPException(
+ detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST
+ )
+
+ if data.max_withdrawable < data.min_withdrawable:
+ raise HTTPException(
+ detail="`max_withdrawable` needs to be at least `min_withdrawable`.",
+ status_code=HTTPStatus.BAD_REQUEST,
)
usescsv = ""
- for i in range(g.data["uses"]):
- if g.data["is_unique"]:
+ for i in range(data.uses):
+ if data.is_unique:
usescsv += "," + str(i + 1)
else:
usescsv += "," + str(1)
@@ -100,45 +93,44 @@ async def api_link_create_or_update(link_id=None):
if link_id:
link = await get_withdraw_link(link_id, 0)
if not link:
- return (
- jsonify({"message": "Withdraw link does not exist."}),
- HTTPStatus.NOT_FOUND,
+ raise HTTPException(
+ detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
- if link.wallet != g.wallet.id:
- return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN
- link = await update_withdraw_link(link_id, **g.data, usescsv=usescsv, used=0)
+ if link.wallet != wallet.wallet.id:
+ raise HTTPException(
+ detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
+ )
+ link = await update_withdraw_link(
+ link_id, **data.dict(), usescsv=usescsv, used=0
+ )
else:
link = await create_withdraw_link(
- wallet_id=g.wallet.id, **g.data, usescsv=usescsv
+ wallet_id=wallet.wallet.id, data=data, usescsv=usescsv
)
-
- return (
- jsonify({**link._asdict(), **{"lnurl": link.lnurl}}),
- HTTPStatus.OK if link_id else HTTPStatus.CREATED,
- )
+ return {**link.dict(), **{"lnurl": link.lnurl(req)}}
-@withdraw_ext.route("/api/v1/links/", methods=["DELETE"])
-@api_check_wallet_key("admin")
-async def api_link_delete(link_id):
+@withdraw_ext.delete("/api/v1/links/{link_id}")
+async def api_link_delete(link_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
link = await get_withdraw_link(link_id)
if not link:
- return (
- jsonify({"message": "Withdraw link does not exist."}),
- HTTPStatus.NOT_FOUND,
+ raise HTTPException(
+ detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
- if link.wallet != g.wallet.id:
- return jsonify({"message": "Not your withdraw link."}), HTTPStatus.FORBIDDEN
+ if link.wallet != wallet.wallet.id:
+ raise HTTPException(
+ detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
+ )
await delete_withdraw_link(link_id)
-
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-@withdraw_ext.route("/api/v1/links//", methods=["GET"])
-@api_check_wallet_key("invoice")
-async def api_hash_retrieve(the_hash, lnurl_id):
+@withdraw_ext.get("/api/v1/links/{the_hash}/{lnurl_id}", status_code=HTTPStatus.OK)
+async def api_hash_retrieve(
+ the_hash, lnurl_id, wallet: WalletTypeInfo = Depends(get_key_type)
+):
hashCheck = await get_hash_check(the_hash, lnurl_id)
- return jsonify(hashCheck), HTTPStatus.OK
+ return hashCheck
diff --git a/lnbits/helpers.py b/lnbits/helpers.py
index 0370edbc..cb6f8ee7 100644
--- a/lnbits/helpers.py
+++ b/lnbits/helpers.py
@@ -1,16 +1,21 @@
+import glob
import json
import os
-import glob
+from typing import Any, List, NamedTuple, Optional
+
+import jinja2
import shortuuid # type: ignore
-from typing import List, NamedTuple, Optional
+from lnbits.jinja2_templating import Jinja2Templates
+from lnbits.requestvars import g
-from .settings import LNBITS_DISABLED_EXTENSIONS, LNBITS_PATH
+import lnbits.settings as settings
class Extension(NamedTuple):
code: str
is_valid: bool
+ is_admin_only: bool
name: Optional[str] = None
short_description: Optional[str] = None
icon: Optional[str] = None
@@ -20,32 +25,41 @@ class Extension(NamedTuple):
class ExtensionManager:
def __init__(self):
- self._disabled: List[str] = LNBITS_DISABLED_EXTENSIONS
+ self._disabled: List[str] = settings.LNBITS_DISABLED_EXTENSIONS
+ self._admin_only: List[str] = [x.strip(' ') for x in settings.LNBITS_ADMIN_EXTENSIONS]
self._extension_folders: List[str] = [
- x[1] for x in os.walk(os.path.join(LNBITS_PATH, "extensions"))
+ x[1] for x in os.walk(os.path.join(settings.LNBITS_PATH, "extensions"))
][0]
@property
def extensions(self) -> List[Extension]:
output = []
+ if "all" in self._disabled:
+ return output
+
for extension in [
ext for ext in self._extension_folders if ext not in self._disabled
]:
try:
with open(
- os.path.join(LNBITS_PATH, "extensions", extension, "config.json")
+ os.path.join(
+ settings.LNBITS_PATH, "extensions", extension, "config.json"
+ )
) as json_file:
config = json.load(json_file)
is_valid = True
+ is_admin_only = True if extension in self._admin_only else False
except Exception:
config = {}
is_valid = False
+ is_admin_only = False
output.append(
Extension(
extension,
is_valid,
+ is_admin_only,
config.get("name"),
config.get("short_description"),
config.get("icon"),
@@ -102,7 +116,7 @@ def get_css_vendored(prefer_minified: bool = False) -> List[str]:
def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
paths: List[str] = []
for path in glob.glob(
- os.path.join(LNBITS_PATH, "static/vendor/**"), recursive=True
+ os.path.join(settings.LNBITS_PATH, "static/vendor/**"), recursive=True
):
if path.endswith(".min" + ext):
# path is minified
@@ -128,4 +142,40 @@ def get_vendored(ext: str, prefer_minified: bool = False) -> List[str]:
def url_for_vendored(abspath: str) -> str:
- return "/" + os.path.relpath(abspath, LNBITS_PATH)
+ return "/" + os.path.relpath(abspath, settings.LNBITS_PATH)
+
+
+def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> str:
+ base = g().base_url if external else ""
+ url_params = "?"
+ for key in params:
+ url_params += f"{key}={params[key]}&"
+ url = f"{base}{endpoint}{url_params}"
+ return url
+
+
+def template_renderer(additional_folders: List = []) -> Jinja2Templates:
+ t = Jinja2Templates(
+ loader=jinja2.FileSystemLoader(
+ ["lnbits/templates", "lnbits/core/templates", *additional_folders]
+ )
+ )
+ if settings.LNBITS_AD_SPACE:
+ t.env.globals["AD_SPACE"] = settings.LNBITS_AD_SPACE
+ t.env.globals["HIDE_API"] = settings.LNBITS_HIDE_API
+ t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE
+ t.env.globals["LNBITS_DENOMINATION"] = settings.LNBITS_DENOMINATION
+ t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE
+ t.env.globals["SITE_DESCRIPTION"] = settings.LNBITS_SITE_DESCRIPTION
+ t.env.globals["LNBITS_THEME_OPTIONS"] = settings.LNBITS_THEME_OPTIONS
+ t.env.globals["LNBITS_VERSION"] = settings.LNBITS_COMMIT
+ t.env.globals["EXTENSIONS"] = get_valid_extensions()
+
+ if settings.DEBUG:
+ t.env.globals["VENDORED_JS"] = map(url_for_vendored, get_js_vendored())
+ t.env.globals["VENDORED_CSS"] = map(url_for_vendored, get_css_vendored())
+ else:
+ t.env.globals["VENDORED_JS"] = ["/static/bundle.js"]
+ t.env.globals["VENDORED_CSS"] = ["/static/bundle.css"]
+
+ return t
diff --git a/lnbits/jinja2_templating.py b/lnbits/jinja2_templating.py
new file mode 100644
index 00000000..5abcd0bf
--- /dev/null
+++ b/lnbits/jinja2_templating.py
@@ -0,0 +1,37 @@
+# Borrowed from the excellent accent-starlette
+# https://github.com/accent-starlette/starlette-core/blob/master/starlette_core/templating.py
+
+import typing
+
+from starlette import templating
+from starlette.datastructures import QueryParams
+from starlette.requests import Request
+
+from lnbits.requestvars import g
+
+try:
+ import jinja2
+except ImportError: # pragma: nocover
+ jinja2 = None # type: ignore
+
+
+class Jinja2Templates(templating.Jinja2Templates):
+ def __init__(self, loader: jinja2.BaseLoader) -> None:
+ assert jinja2 is not None, "jinja2 must be installed to use Jinja2Templates"
+ self.env = self.get_environment(loader)
+
+ def get_environment(self, loader: "jinja2.BaseLoader") -> "jinja2.Environment":
+ @jinja2.contextfunction
+ def url_for(context: dict, name: str, **path_params: typing.Any) -> str:
+ request: Request = context["request"]
+ return request.app.url_path_for(name, **path_params)
+
+ def url_params_update(init: QueryParams, **new: typing.Any) -> QueryParams:
+ values = dict(init)
+ values.update(new)
+ return QueryParams(**values)
+
+ env = jinja2.Environment(loader=loader, autoescape=True)
+ env.globals["url_for"] = url_for
+ env.globals["url_params_update"] = url_params_update
+ return env
diff --git a/lnbits/lnurl.py b/lnbits/lnurl.py
new file mode 100644
index 00000000..4c285da1
--- /dev/null
+++ b/lnbits/lnurl.py
@@ -0,0 +1,16 @@
+from bech32 import bech32_decode, bech32_encode, convertbits
+
+
+def decode(lnurl: str) -> str:
+ hrp, data = bech32_decode(lnurl)
+ assert data
+ bech32_data = convertbits(data, 5, 8, False)
+ assert bech32_data
+ return bytes(bech32_data).decode("utf-8")
+
+
+def encode(url: str) -> str:
+ bech32_data = convertbits(url.encode("utf-8"), 8, 5, True)
+ assert bech32_data
+ lnurl = bech32_encode("lnurl", bech32_data)
+ return lnurl.upper()
diff --git a/lnbits/requestvars.py b/lnbits/requestvars.py
new file mode 100644
index 00000000..2f7139d8
--- /dev/null
+++ b/lnbits/requestvars.py
@@ -0,0 +1,10 @@
+import contextvars
+import types
+
+request_global = contextvars.ContextVar(
+ "request_global", default=types.SimpleNamespace()
+)
+
+
+def g() -> types.SimpleNamespace:
+ return request_global.get()
diff --git a/lnbits/settings.py b/lnbits/settings.py
index b42d06ec..9ccd9e4e 100644
--- a/lnbits/settings.py
+++ b/lnbits/settings.py
@@ -23,13 +23,30 @@ LNBITS_PATH = path.dirname(path.realpath(__file__))
LNBITS_DATA_FOLDER = env.str(
"LNBITS_DATA_FOLDER", default=path.join(LNBITS_PATH, "data")
)
+LNBITS_DATABASE_URL = env.str("LNBITS_DATABASE_URL", default=None)
+
LNBITS_ALLOWED_USERS: List[str] = env.list(
"LNBITS_ALLOWED_USERS", default=[], subcast=str
)
+LNBITS_ADMIN_USERS: List[str] = env.list("LNBITS_ADMIN_USERS", default=[], subcast=str)
+LNBITS_ADMIN_EXTENSIONS: List[str] = env.list("LNBITS_ADMIN_EXTENSIONS", default=[], subcast=str)
LNBITS_DISABLED_EXTENSIONS: List[str] = env.list(
"LNBITS_DISABLED_EXTENSIONS", default=[], subcast=str
)
+
+LNBITS_AD_SPACE = env.list("LNBITS_AD_SPACE", default=[])
+LNBITS_HIDE_API = env.bool("LNBITS_HIDE_API", default=False)
LNBITS_SITE_TITLE = env.str("LNBITS_SITE_TITLE", default="LNbits")
+LNBITS_DENOMINATION = env.str("LNBITS_DENOMINATION", default="sats")
+LNBITS_SITE_TAGLINE = env.str(
+ "LNBITS_SITE_TAGLINE", default="free and open-source lightning wallet"
+)
+LNBITS_SITE_DESCRIPTION = env.str("LNBITS_SITE_DESCRIPTION", default="")
+LNBITS_THEME_OPTIONS: List[str] = env.list(
+ "LNBITS_THEME_OPTIONS",
+ default="classic, flamingo, mint, salvador, monochrome, autumn",
+ subcast=str,
+)
WALLET = wallet_class()
DEFAULT_WALLET_NAME = env.str("LNBITS_DEFAULT_WALLET_NAME", default="LNbits wallet")
@@ -40,8 +57,7 @@ SERVICE_FEE = env.float("LNBITS_SERVICE_FEE", default=0.0)
try:
LNBITS_COMMIT = (
subprocess.check_output(
- ["git", "-C", LNBITS_PATH, "rev-parse", "HEAD"],
- stderr=subprocess.DEVNULL,
+ ["git", "-C", LNBITS_PATH, "rev-parse", "HEAD"], stderr=subprocess.DEVNULL
)
.strip()
.decode("ascii")
diff --git a/lnbits/static/fonts/material-icons-v50.woff2 b/lnbits/static/fonts/material-icons-v50.woff2
index 34cdd2af..b283233d 100644
Binary files a/lnbits/static/fonts/material-icons-v50.woff2 and b/lnbits/static/fonts/material-icons-v50.woff2 differ
diff --git a/lnbits/static/images/templatead.png b/lnbits/static/images/templatead.png
new file mode 100644
index 00000000..c369b91a
Binary files /dev/null and b/lnbits/static/images/templatead.png differ
diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js
index cebd5cfc..c8863b5a 100644
--- a/lnbits/static/js/base.js
+++ b/lnbits/static/js/base.js
@@ -110,6 +110,9 @@ window.LNbits = {
window.location.href =
'/wallet?' + (userId ? 'usr=' + userId + '&' : '') + 'nme=' + walletName
},
+ updateWallet: function (walletName, userId, walletId) {
+ window.location.href = `/wallet?usr=${userId}&wal=${walletId}&nme=${walletName}`
+ },
deleteWallet: function (walletId, userId) {
window.location.href = '/deletewallet?usr=' + userId + '&wal=' + walletId
}
@@ -120,6 +123,7 @@ window.LNbits = {
[
'code',
'isValid',
+ 'isAdminOnly',
'name',
'shortDescription',
'icon',
@@ -132,7 +136,12 @@ window.LNbits = {
return obj
},
user: function (data) {
- var obj = _.object(['id', 'email', 'extensions', 'wallets'], data)
+ var obj = {
+ id: data.id,
+ email: data.email,
+ extensions: data.extensions,
+ wallets: data.wallets
+ }
var mapWallet = this.wallet
obj.wallets = obj.wallets
.map(function (obj) {
@@ -150,35 +159,37 @@ window.LNbits = {
return obj
},
wallet: function (data) {
- var obj = _.object(
- ['id', 'name', 'user', 'adminkey', 'inkey', 'balance'],
- data
+ newWallet = {
+ id: data.id,
+ name: data.name,
+ adminkey: data.adminkey,
+ inkey: data.inkey
+ }
+ newWallet.msat = data.balance_msat
+ newWallet.sat = Math.round(data.balance_msat / 1000)
+ newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format(
+ newWallet.sat
)
- obj.msat = obj.balance
- obj.sat = Math.round(obj.balance / 1000)
- obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
- obj.url = ['/wallet?usr=', obj.user, '&wal=', obj.id].join('')
- return obj
+ newWallet.url = ['/wallet?usr=', data.user, '&wal=', data.id].join('')
+ return newWallet
},
payment: function (data) {
- var obj = _.object(
- [
- 'checking_id',
- 'pending',
- 'amount',
- 'fee',
- 'memo',
- 'time',
- 'bolt11',
- 'preimage',
- 'payment_hash',
- 'extra',
- 'wallet_id',
- 'webhook',
- 'webhook_status'
- ],
- data
- )
+ obj = {
+ checking_id: data.id,
+ pending: data.pending,
+ amount: data.amount,
+ fee: data.fee,
+ memo: data.memo,
+ time: data.time,
+ bolt11: data.bolt11,
+ preimage: data.preimage,
+ payment_hash: data.payment_hash,
+ extra: data.extra,
+ wallet_id: data.wallet_id,
+ webhook: data.webhook,
+ webhook_status: data.webhook_status
+ }
+
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
@@ -190,7 +201,7 @@ window.LNbits = {
obj.fsat = new Intl.NumberFormat(window.LOCALE).format(obj.sat)
obj.isIn = obj.amount > 0
obj.isOut = obj.amount < 0
- obj.isPaid = obj.pending === 0
+ obj.isPaid = !obj.pending
obj._q = [obj.memo, obj.sat].join(' ').toLowerCase()
return obj
}
@@ -227,7 +238,8 @@ window.LNbits = {
Quasar.plugins.Notify.create({
timeout: 5000,
type: types[error.response.status] || 'warning',
- message: error.response.data.message || null,
+ message:
+ error.response.data.message || error.response.data.detail || null,
caption:
[error.response.status, ' ', error.response.statusText]
.join('')
@@ -307,11 +319,17 @@ window.windowMixin = {
extensions: [],
user: null,
wallet: null,
- payments: []
+ payments: [],
+ allowedThemes: null
}
}
},
+
methods: {
+ changeColor: function (newValue) {
+ document.body.setAttribute('data-theme', newValue)
+ this.$q.localStorage.set('lnbits.theme', newValue)
+ },
toggleDarkMode: function () {
this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
@@ -327,7 +345,35 @@ window.windowMixin = {
}
},
created: function () {
- this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
+
+ if(this.$q.localStorage.getItem('lnbits.darkMode') == true || this.$q.localStorage.getItem('lnbits.darkMode') == false){
+ this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
+ }
+ else{
+ this.$q.dark.set(true)
+ }
+ this.g.allowedThemes = window.allowedThemes ?? ['bitcoin']
+
+ // failsafe if admin changes themes halfway
+ if (!this.$q.localStorage.getItem('lnbits.theme')){
+ this.changeColor(this.g.allowedThemes[0])
+ }
+ if (
+ this.$q.localStorage.getItem('lnbits.theme') &&
+ !this.g.allowedThemes.includes(
+ this.$q.localStorage.getItem('lnbits.theme')
+ )
+ ) {
+ this.changeColor(this.g.allowedThemes[0])
+ }
+
+ if (this.$q.localStorage.getItem('lnbits.theme')) {
+ document.body.setAttribute(
+ 'data-theme',
+ this.$q.localStorage.getItem('lnbits.theme')
+ )
+ }
+
if (window.user) {
this.g.user = Object.freeze(window.LNbits.map.user(window.user))
}
@@ -344,6 +390,10 @@ window.windowMixin = {
.filter(function (obj) {
return !obj.hidden
})
+ .filter(function (obj) {
+ if (window.user.admin) return obj
+ return !obj.isAdminOnly
+ })
.map(function (obj) {
if (user) {
obj.isEnabled = user.extensions.indexOf(obj.code) !== -1
diff --git a/lnbits/static/js/components.js b/lnbits/static/js/components.js
index e1faf2fe..b8c9f4d0 100644
--- a/lnbits/static/js/components.js
+++ b/lnbits/static/js/components.js
@@ -22,7 +22,8 @@ Vue.component('lnbits-wallet-list', {
activeWallet: null,
activeBalance: [],
showForm: false,
- walletName: ''
+ walletName: '',
+ LNBITS_DENOMINATION: LNBITS_DENOMINATION
}
},
template: `
@@ -35,7 +36,7 @@ Vue.component('lnbits-wallet-list', {
@@ -43,7 +44,8 @@ Vue.component('lnbits-wallet-list', {
{{ wallet.name }}
- {{ wallet.live_fsat }} sat
+ {{ parseFloat(String(wallet.live_fsat).replaceAll(",", "")) / 100 }} {{ LNBITS_DENOMINATION }}
+ {{ wallet.live_fsat }} {{ LNBITS_DENOMINATION }}
@@ -118,7 +120,7 @@ Vue.component('lnbits-extension-list', {
@@ -194,11 +196,11 @@ Vue.component('lnbits-payment-details', {
Amount:
- {{ (payment.amount / 1000).toFixed(3) }} sat
+ {{ (payment.amount / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
Fee:
- {{ (payment.fee / 1000).toFixed(3) }} sat
+ {{ (payment.fee / 1000).toFixed(3) }} {{LNBITS_DENOMINATION}}
Payment hash:
@@ -219,7 +221,7 @@ Vue.component('lnbits-payment-details', {
-
+
extra
{{ entry.key }}:
diff --git a/lnbits/static/scss/base.scss b/lnbits/static/scss/base.scss
index 2af63bab..3668e773 100644
--- a/lnbits/static/scss/base.scss
+++ b/lnbits/static/scss/base.scss
@@ -1,108 +1,143 @@
-$dark-background: #1f2234;
-$dark-card-background: #333646;
+$themes: ( 'classic': ( primary: #673ab7, secondary: #9c27b0, dark: #1f2234, info: #333646, marginal-bg: #1f2234, marginal-text: #fff), 'bitcoin': ( primary: #ff9853, secondary: #ff7353, dark: #2d293b, info: #333646, marginal-bg: #2d293b, marginal-text: #fff), 'freedom': ( primary: #e22156, secondary: #b91a45, dark: #0a0a0a, info: #1b1b1b, marginal-bg: #2d293b, marginal-text: #fff), 'mint': ( primary: #3ab77d, secondary: #27b065, dark: #1f342b, info: #334642, marginal-bg: #1f342b, marginal-text: #fff), 'autumn': ( primary: #b7763a, secondary: #b07927, dark: #34291f, info: #463f33, marginal-bg: #342a1f, marginal-text: rgb(255, 255, 255)), 'flamingo': ( primary: #d11d53, secondary: #db3e6d, dark: #803a45, info: #ec7599, marginal-bg: #803a45, marginal-text: rgb(255, 255, 255)), 'monochrome': ( primary: #494949, secondary: #6b6b6b, dark: #000, info: rgb(39, 39, 39), marginal-bg: #000, marginal-text: rgb(255, 255, 255)));
+@each $theme,
+$colors in $themes {
+ @each $name,
+ $color in $colors {
+ @if $name=='dark' {
+ [data-theme='#{$theme}'] .q-drawer--dark,
+ body[data-theme='#{$theme}'].body--dark,
+ [data-theme='#{$theme}'] .q-menu--dark {
+ background: $color !important;
+ }
+ /* IF WANTING TO SET A DARKER BG COLOR IN THE FUTURE
+ // set a darker body bg for all themes, when in "dark mode"
+ body[data-theme='#{$theme}'].body--dark {
+ background: scale-color($color, $lightness: -60%);
+ }
+ */
+ }
+ @if $name=='info' {
+ [data-theme='#{$theme}'] .q-card--dark,
+ [data-theme='#{$theme}'] .q-stepper--dark {
+ background: $color !important;
+ }
+ }
+ }
+ [data-theme='#{$theme}'] {
+ @each $name,
+ $color in $colors {
+ .bg-#{$name} {
+ background: $color !important;
+ }
+ .text-#{$name} {
+ color: $color !important;
+ }
+ }
+ }
+}
+[data-theme='freedom'] .q-drawer--dark {
+ background: #0a0a0a !important;
+}
+
+[data-theme='freedom'] .q-header {
+ background: #0a0a0a !important;
+}
+
+[data-theme='salvador'] .q-drawer--dark {
+ background: #242424 !important;
+}
+
+[data-theme='salvador'] .q-header {
+ background: #0f47af !important;
+}
+
+[data-theme='flamingo'] .q-drawer--dark {
+ background: #e75480 !important;
+}
+
+[data-theme='flamingo'] .q-header {
+ background: #e75480 !important;
+}
[v-cloak] {
- display: none;
-}
-
-.bg-lnbits-dark {
- background-color: $dark-background;
-}
-
-body.body--dark,
-body.body--dark .q-drawer--dark,
-body.body--dark .q-menu--dark {
- background: $dark-background;
-}
-
-body.body--dark .q-card--dark {
- background: $dark-card-background;
+ display: none;
}
body.body--dark .q-table--dark {
- background: transparent;
-}
-
-body.body--light,
-body.body--light .q-drawer {
- background: whitesmoke;
+ background: transparent;
}
body.body--dark .q-field--error {
- .text-negative,
- .q-field__messages {
- color: yellow !important;
- }
+ .text-negative,
+ .q-field__messages {
+ color: yellow !important;
+ }
}
.lnbits-drawer__q-list .q-item {
- padding-top: 5px !important;
- padding-bottom: 5px !important;
- border-top-right-radius: 3px;
- border-bottom-right-radius: 3px;
-
- &.q-item--active {
- color: inherit;
- font-weight: bold;
- }
+ padding-top: 5px !important;
+ padding-bottom: 5px !important;
+ border-top-right-radius: 3px;
+ border-bottom-right-radius: 3px;
+ &.q-item--active {
+ color: inherit;
+ font-weight: bold;
+ }
}
.lnbits__dialog-card {
- width: 500px;
+ width: 500px;
}
.q-table--dense {
- th:first-child,
- td:first-child,
- .q-table__bottom {
- padding-left: 6px !important;
- }
-
- th:last-child,
- td:last-child,
- .q-table__bottom {
- padding-right: 6px !important;
- }
+ th:first-child,
+ td:first-child,
+ .q-table__bottom {
+ padding-left: 6px !important;
+ }
+ th:last-child,
+ td:last-child,
+ .q-table__bottom {
+ padding-right: 6px !important;
+ }
}
a.inherit {
- color: inherit;
- text-decoration: none;
+ color: inherit;
+ text-decoration: none;
}
// QR video
-
video {
- border-radius: 3px;
+ border-radius: 3px;
}
// Material icons font
-
@font-face {
- font-family: 'Material Icons';
- font-style: normal;
- font-weight: 400;
- src: url(../fonts/material-icons-v50.woff2) format('woff2');
+ font-family: 'Material Icons';
+ font-style: normal;
+ font-weight: 400;
+ src: url(../fonts/material-icons-v50.woff2) format('woff2');
}
.material-icons {
- font-family: 'Material Icons';
- font-weight: normal;
- font-style: normal;
- font-size: 24px;
- line-height: 1;
- letter-spacing: normal;
- text-transform: none;
- display: inline-block;
- white-space: nowrap;
- word-wrap: normal;
- direction: ltr;
- -moz-font-feature-settings: 'liga';
- -moz-osx-font-smoothing: grayscale;
+ font-family: 'Material Icons';
+ font-weight: normal;
+ font-style: normal;
+ font-size: 24px;
+ line-height: 1;
+ letter-spacing: normal;
+ text-transform: none;
+ display: inline-block;
+ white-space: nowrap;
+ word-wrap: normal;
+ direction: ltr;
+ -moz-font-feature-settings: 'liga';
+ -moz-osx-font-smoothing: grayscale;
}
// text-wrap
.text-wrap {
- word-break: break-word;
-}
+ word-break: break-word;
+}
\ No newline at end of file
diff --git a/lnbits/tasks.py b/lnbits/tasks.py
index 27f141b0..4e73a0af 100644
--- a/lnbits/tasks.py
+++ b/lnbits/tasks.py
@@ -1,8 +1,10 @@
import time
-import trio
+import asyncio
+import traceback
from http import HTTPStatus
-from typing import Optional, List, Callable
-from quart_trio import QuartTrio
+from typing import List, Callable
+
+from fastapi.exceptions import HTTPException
from lnbits.settings import WALLET
from lnbits.core.crud import (
@@ -24,19 +26,32 @@ def record_async(func: Callable) -> Callable:
return recorder
-def run_deferred_async(nursery):
+async def run_deferred_async():
for func in deferred_async:
- nursery.start_soon(func)
+ asyncio.create_task(catch_everything_and_restart(func))
+
+
+async def catch_everything_and_restart(func):
+ try:
+ await func()
+ except asyncio.CancelledError:
+ raise # because we must pass this up
+ except Exception as exc:
+ print("caught exception in background task:", exc)
+ print(traceback.format_exc())
+ print("will restart the task in 5 seconds.")
+ await asyncio.sleep(5)
+ await catch_everything_and_restart(func)
async def send_push_promise(a, b) -> None:
pass
-invoice_listeners: List[trio.MemorySendChannel] = []
+invoice_listeners: List[asyncio.Queue] = []
-def register_invoice_listener(send_chan: trio.MemorySendChannel):
+def register_invoice_listener(send_chan: asyncio.Queue):
"""
A method intended for extensions to call when they want to be notified about
new invoice payments incoming.
@@ -48,20 +63,22 @@ async def webhook_handler():
handler = getattr(WALLET, "webhook_listener", None)
if handler:
return await handler()
- return "", HTTPStatus.NO_CONTENT
+ raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
-internal_invoice_paid, internal_invoice_received = trio.open_memory_channel(0)
+internal_invoice_queue = asyncio.Queue(0)
-async def internal_invoice_listener(nursery):
- async for checking_id in internal_invoice_received:
- nursery.start_soon(invoice_callback_dispatcher, checking_id)
+async def internal_invoice_listener():
+ while True:
+ checking_id = await internal_invoice_queue.get()
+ asyncio.create_task(invoice_callback_dispatcher(checking_id))
-async def invoice_listener(nursery):
+async def invoice_listener():
async for checking_id in WALLET.paid_invoices_stream():
- nursery.start_soon(invoice_callback_dispatcher, checking_id)
+ print("> got a payment notification", checking_id)
+ asyncio.create_task(invoice_callback_dispatcher(checking_id))
async def check_pending_payments():
@@ -85,7 +102,7 @@ async def check_pending_payments():
# that will be handled by the global invoice listeners, hopefully
incoming = False
- await trio.sleep(60 * 30) # every 30 minutes
+ await asyncio.sleep(60 * 30) # every 30 minutes
async def perform_balance_checks():
@@ -93,7 +110,7 @@ async def perform_balance_checks():
for bc in await get_balance_checks():
redeem_lnurl_withdraw(bc.wallet, bc.url)
- await trio.sleep(60 * 60 * 6) # every 6 hours
+ await asyncio.sleep(60 * 60 * 6) # every 6 hours
async def invoice_callback_dispatcher(checking_id: str):
@@ -101,4 +118,4 @@ async def invoice_callback_dispatcher(checking_id: str):
if payment and payment.is_in:
await payment.set_pending(False)
for send_chan in invoice_listeners:
- await send_chan.send(payment)
+ await send_chan.put(payment)
diff --git a/lnbits/templates/base.html b/lnbits/templates/base.html
index 1e39b608..d61a0ccf 100644
--- a/lnbits/templates/base.html
+++ b/lnbits/templates/base.html
@@ -2,7 +2,7 @@
- {% for url in g.VENDORED_CSS %}
+ {% for url in VENDORED_CSS %}
{% endfor %}
@@ -19,9 +19,9 @@
{% block head_scripts %}{% endblock %}
-
+
-
+
{% block drawer_toggle %}
{% endblock %}
- {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{
- SITE_TITLE }} {% else %}
- LNbits {% endif %} {% endblock %}
+
+ {% block toolbar_title %} {% if SITE_TITLE != 'LNbits' %} {{
+ SITE_TITLE }} {% else %} LNbits {% endif %} {%
+ endblock %}
{% block beta %}
-
+
USE WITH CAUTION - LNbits wallet is still in USE WITH CAUTION - {{SITE_TITLE}} wallet is still in BETA
{% endblock %}
+
+
+ classic
+
+ bitcoin
+
+ mint autumn
+
+ monochrome
+
+ elSalvador
+
+ Freedom
+
+ flamingo
+
+
+
+
Toggle Dark Mode
@@ -78,22 +171,25 @@
{% endblock %} {% block footer %}
+
-
+
- LNbits, free and open-source lightning
- wallet/accounts system
+ {{ SITE_TITLE }}, {{SITE_TAGLINE}}
- Commit version: {{LNBITS_VERSION}}
+
+ Commit version: {{LNBITS_VERSION}}
+
{% endblock %}
{% block vue_templates %}{% endblock %}
- {% for url in g.VENDORED_JS %}
+ {% for url in VENDORED_JS %}
{% endfor %}
+
{% block scripts %}{% endblock %}
diff --git a/lnbits/templates/error.html b/lnbits/templates/error.html
new file mode 100644
index 00000000..43a9ad8a
--- /dev/null
+++ b/lnbits/templates/error.html
@@ -0,0 +1,36 @@
+{% extends "public.html" %} {% block page %}
+
+
+
+
+
+ Error
+
+
+
+ {{ err }}
+ If you believe this shouldn't be an error please bring it up on https://t.me/lnbits
+
+
+
+
+
+
+ {% endblock %} {% block scripts %}
+
+
+
+ {% endblock %}
+
\ No newline at end of file
diff --git a/lnbits/templates/print.html b/lnbits/templates/print.html
index 3b0d0782..0cfc6c64 100644
--- a/lnbits/templates/print.html
+++ b/lnbits/templates/print.html
@@ -2,7 +2,7 @@
- {% for url in g.VENDORED_CSS %}
+ {% for url in VENDORED_CSS %}
{% endfor %}