diff --git a/lnbits/app.py b/lnbits/app.py index 7e53c0e1..288caa5b 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -95,9 +95,14 @@ def register_routes(app: FastAPI) -> None: try: ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}") ext_route = getattr(ext_module, f"{ext.code}_ext") + + ext_statics = getattr(ext_module, f"{ext.code}_static_files") + for s in ext_statics: + app.mount(s["path"], s["app"], s["name"]) app.include_router(ext_route) - except Exception: + except Exception as e: + print(str(e)) raise ImportError( f"Please make sure that the extension `{ext.code}` follows conventions." ) diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py index 0985701a..d0912a64 100644 --- a/lnbits/extensions/offlineshop/__init__.py +++ b/lnbits/extensions/offlineshop/__init__.py @@ -1,15 +1,41 @@ -from fastapi import APIRouter +from fastapi import APIRouter, FastAPI +from fastapi.staticfiles import StaticFiles +from starlette.routing import Mount from lnbits.db import Database +from lnbits.helpers import template_renderer db = Database("ext_offlineshop") +offlineshop_static_files = [ + { + "path": "/offlineshop/static", + "app": StaticFiles(directory="lnbits/extensions/offlineshop/static"), + "name": "offlineshop_static", + } +] + offlineshop_ext: APIRouter = APIRouter( - prefix="/Extension", - tags=["Offlineshop"] + prefix="/offlineshop", + tags=["Offlineshop"], + # routes=[ + # Mount( + # "/static", + # app=StaticFiles(directory="lnbits/extensions/offlineshop/static"), + # name="offlineshop_static", + # ) + # ], ) -from .views_api import * # noqa -from .views import * # noqa +def offlineshop_renderer(): + return template_renderer( + [ + "lnbits/extensions/offlineshop/templates", + ] + ) + + from .lnurl import * # noqa +from .views import * # noqa +from .views_api import * # noqa diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py index 8ebf7fa1..72ef2a42 100644 --- a/lnbits/extensions/offlineshop/lnurl.py +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -1,4 +1,8 @@ import hashlib +from fastapi.params import Query + +from starlette.requests import Request +from lnbits.helpers import url_for from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore from lnbits.core.services import create_invoice @@ -8,8 +12,8 @@ from . import offlineshop_ext from .crud import get_shop, get_item -@offlineshop_ext.get("/lnurl/") -async def lnurl_response(item_id): +@offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response") +async def lnurl_response(item_id: int = Query(...)): item = await get_item(item_id) if not item: return {"status": "ERROR", "reason": "Item not found."} @@ -34,7 +38,7 @@ async def lnurl_response(item_id): @offlineshop_ext.get("/lnurl/cb/") -async def lnurl_callback(item_id): +async def lnurl_callback(request: Request, item_id: int): item = await get_item(item_id) if not item: return {"status": "ERROR", "reason": "Couldn't find item."} @@ -51,12 +55,12 @@ async def lnurl_callback(item_id): amount_received = int(request.args.get("amount") or 0) if amount_received < min: return LnurlErrorResponse( - reason=f"Amount {amount_received} is smaller than minimum {min}." - ).dict() + reason=f"Amount {amount_received} is smaller than minimum {min}." + ).dict() elif amount_received > max: return LnurlErrorResponse( - reason=f"Amount {amount_received} is greater than maximum {max}." - ).dict() + reason=f"Amount {amount_received} is greater than maximum {max}." + ).dict() shop = await get_shop(item.shop) @@ -75,7 +79,7 @@ async def lnurl_callback(item_id): 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=[], ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py index fa798e5c..53fdb845 100644 --- a/lnbits/extensions/offlineshop/models.py +++ b/lnbits/extensions/offlineshop/models.py @@ -8,6 +8,7 @@ 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 = {} @@ -82,20 +83,16 @@ class Item(BaseModel): 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 values(self, req: Request): + values = self.dict() + values["lnurl"] = lnurl_encode( + req.url_for("offlineshop.lnurl_response", item_id=self.id) ) - - def values(self): - values = self._asdict() - values["lnurl"] = self.lnurl return values async def lnurlpay_metadata(self) -> LnurlPayMetadata: @@ -107,14 +104,14 @@ class Item(BaseModel): 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..d3d1e53a 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -64,7 +64,7 @@ curl -X GET {{ request.url_root }}/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">}' @@ -97,7 +97,7 @@
Curl example
curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H - "X-Api-Key: {{ g.user.wallets[0].inkey }}" + "X-Api-Key: {{ user.wallets[0].inkey }}" @@ -120,7 +120,7 @@ >curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop/items/<item_id> -H "Content-Type: application/json" -H "X-Api-Key: {{ - g.user.wallets[0].inkey }}" -d '{"name": <string>, + user.wallets[0].inkey }}" -d '{"name": <string>, "description": <string>, "image": <data-uri string>, "price": <integer>, "unit": <"sat" or "USD">}'
@@ -139,7 +139,7 @@ curl -X GET {{ request.url_root }}/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 7a3a5125..01b8e8da 100644 --- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -331,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 9715c76c..80870b27 100644 --- a/lnbits/extensions/offlineshop/views.py +++ b/lnbits/extensions/offlineshop/views.py @@ -2,25 +2,24 @@ import time from datetime import datetime from http import HTTPStatus +from fastapi.params import Depends +from starlette.responses import HTMLResponse + from lnbits.decorators import check_user_exists -from lnbits.core.models import Payment +from lnbits.core.models import Payment, User from lnbits.core.crud import get_standalone_payment -from . import offlineshop_ext +from . import offlineshop_ext, offlineshop_renderer from .crud import get_item, get_shop -from fastapi import FastAPI, Request -from fastapi.templating import Jinja2Templates - -templates = Jinja2Templates(directory="templates") - -@offlineshop_ext.get("/") -# @validate_uuids(["usr"], required=True) -# @check_user_exists() -async def index(request: Request): - return await templates.TemplateResponse("offlineshop/index.html", {"request": request,"user":g.user}) +from fastapi import Request, HTTPException -@offlineshop_ext.get("/print") +@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.get("/print", response_class=HTMLResponse) async def print_qr_codes(request: Request): items = [] for item_id in request.args.get("items").split(","): @@ -34,29 +33,32 @@ async def print_qr_codes(request: Request): } ) - return await templates.TemplateResponse("offlineshop/print.html", {"request": request,"items":items}) + return offlineshop_renderer().TemplateResponse("offlineshop/print.html", {"request": request,"items":items}) @offlineshop_ext.get("/confirmation") -async def confirmation_code(): +async def confirmation_code(p: str): style = "" - payment_hash = request.args.get("p") + payment_hash = p 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." - + style, - 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." + style ) 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 284170ef..58b95935 100644 --- a/lnbits/extensions/offlineshop/views_api.py +++ b/lnbits/extensions/offlineshop/views_api.py @@ -1,10 +1,16 @@ -from typing import Optional +import json + +from typing import List, Optional +from fastapi.params import Depends from pydantic.main import BaseModel from http import HTTPStatus -from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl # type: ignore +from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse # type: ignore -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 from lnbits.requestvars import g @@ -22,46 +28,43 @@ from .models import ShopCounter @offlineshop_ext.get("/api/v1/currencies") async def api_list_currencies_available(): - return jsonify(list(currencies.keys())) + return json.dumps(list(currencies.keys())) @offlineshop_ext.get("/api/v1/offlineshop") -@api_check_wallet_key("invoice") -async def api_shop_from_wallet(): - shop = await get_or_create_shop_by_wallet(g().wallet.id) +# @api_check_wallet_key("invoice") +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 ( - { - **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 ( - { - "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.", ) + class CreateItemsData(BaseModel): - name: str + name: str description: str - image: Optional[str] - price: int - unit: str + image: Optional[str] + price: int + unit: str + @offlineshop_ext.post("/api/v1/offlineshop/items") @offlineshop_ext.put("/api/v1/offlineshop/items/{item_id}") -@api_check_wallet_key("invoice") -async def api_add_or_update_item(data: CreateItemsData, item_id=None): - shop = await get_or_create_shop_by_wallet(g().wallet.id) +# @api_check_wallet_key("invoice") +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, @@ -71,7 +74,7 @@ async def api_add_or_update_item(data: CreateItemsData, item_id=None): data.price, data.unit, ) - return "", HTTPStatus.CREATED + return HTMLResponse(status_code=HTTPStatus.CREATED) else: await update_item( shop.id, @@ -82,36 +85,35 @@ async def api_add_or_update_item(data: CreateItemsData, item_id=None): data.price, data.unit, ) - return "", HTTPStatus.OK @offlineshop_ext.delete("/api/v1/offlineshop/items/{item_id}") -@api_check_wallet_key("invoice") -async def api_delete_item(item_id): - shop = await get_or_create_shop_by_wallet(g().wallet.id) +# @api_check_wallet_key("invoice") +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) class CreateMethodData(BaseModel): - method: str + method: str wordlist: Optional[str] + @offlineshop_ext.put("/api/v1/offlineshop/method") -@api_check_wallet_key("invoice") -async def api_set_method(data: CreateMethodData): +# @api_check_wallet_key("invoice") +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/helpers.py b/lnbits/helpers.py index b1a8c1d8..dbb060a5 100644 --- a/lnbits/helpers.py +++ b/lnbits/helpers.py @@ -149,9 +149,9 @@ def url_for( url = f"{base}{endpoint}{url_params}" return url -def template_renderer() -> Jinja2Templates: +def template_renderer(additional_folders: List = []) -> Jinja2Templates: t = Jinja2Templates( - loader=jinja2.FileSystemLoader(["lnbits/templates", "lnbits/core/templates"]), + loader=jinja2.FileSystemLoader(["lnbits/templates", "lnbits/core/templates", *additional_folders]), ) t.env.globals["SITE_TITLE"] = settings.LNBITS_SITE_TITLE t.env.globals["SITE_TAGLINE"] = settings.LNBITS_SITE_TAGLINE diff --git a/lnbits/jinja2_templating.py b/lnbits/jinja2_templating.py index f3303445..5e3ceba2 100644 --- a/lnbits/jinja2_templating.py +++ b/lnbits/jinja2_templating.py @@ -5,6 +5,7 @@ import typing from starlette import templating from starlette.datastructures import QueryParams +from starlette.requests import Request from lnbits.requestvars import g @@ -22,8 +23,8 @@ class Jinja2Templates(templating.Jinja2Templates): def get_environment(self, loader: "jinja2.BaseLoader") -> "jinja2.Environment": @jinja2.contextfunction def url_for(context: dict, name: str, **path_params: typing.Any) -> str: - request = context["request"] - return request.url_for(name, **path_params) + request: Request = context["request"] # type: starlette.requests.Request + return request.app.url_path_for(name, **path_params) def url_params_update(init: QueryParams, **new: typing.Any) -> QueryParams: values = dict(init)