diff --git a/LICENSE b/LICENSE index 678845a..c97e67a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,39 @@ -MIT License +> Use any license you like, its your extension. -Copyright (c) 2023 LNbits +--- + +# DON'T BE A DICK PUBLIC LICENSE + +> Version 1.1, December 2016 + +> Copyright (C) [year] [fullname] + +Everyone is permitted to copy and distribute verbatim or modified +copies of this license document. + +> DON'T BE A DICK PUBLIC LICENSE +> TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +1. Do whatever you like with the original work, just don't be a dick. + + Being a dick includes - but is not limited to - the following instances: + + 1a. Outright copyright infringement - Don't just copy this and change the name. + 1b. Selling the unmodified original with no work done what-so-ever, that's REALLY being a dick. + 1c. Modifying the original work to contain hidden harmful content. That would make you a PROPER dick. + +2. If you become rich through modifications, related works/services, or supporting the original work, +share the love. Only a dick would make loads off this work and not buy the original work's +creator(s) a pint. + +3. Code is provided with no warranty. Using somebody else's code and bitching when it goes wrong makes +you a DONKEY dick. Fix the problem yourself. A non-dick would submit the fix back. + +--- + +# MIT License + +> Copyright (c) [year] [fullname] Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..d314c71 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Temp - [LNbits](https://github.com/lnbits/lnbits) extension + +For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions) + +## A template extension you can fork and use as a base for a template + +Let the hacking begin! After you have forked this extension you can copy functions from other extensions that you might need. Usually the README is used as a guide of how to us ethe extensiobn. + +### Usage + +1. Enable extension +2. Create a Temp\ + ![create](https://imgur.com/8jNj8Zq.jpg) +3. Open the link for the Temp\ + ![open](https://imgur.com/LZuoWzb.jpg) +4. Press button to generate an invoice and pay\ + ![pay](https://imgur.com/tOwxn77.jpg) \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..357df12 --- /dev/null +++ b/__init__.py @@ -0,0 +1,36 @@ +import asyncio + +from fastapi import APIRouter, Request, Response +from fastapi.routing import APIRoute + +from lnbits.db import Database +from lnbits.helpers import temp_renderer +from lnbits.tasks import catch_everything_and_restart +from typing import Callable +from fastapi.responses import JSONResponse + +db = Database("ext_temp") + +temp_ext: APIRouter = APIRouter( + prefix="/temp", tags=["Temp"] +) + +temp_static_files = [ + { + "path": "/temp/static", + "name": "temp_static", + } +] + +def temp_renderer(): + return temp_renderer(["temp/temps"]) + + +from .tasks import wait_for_paid_invoices +from .views import * +from .views_api import * + + +def temp_start(): + loop = asyncio.get_event_loop() + loop.create_task(catch_everything_and_restart(wait_for_paid_invoices)) \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..eb94eab --- /dev/null +++ b/config.json @@ -0,0 +1,7 @@ +{ + "name": "Temp", + "short_description": "Minimal extension to build on", + "tile": "/temp/static/image/temp.png", + "contributors": ["arcbtc"], + "min_lnbits_version": "0.0.1" +} diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..e3c8f63 --- /dev/null +++ b/crud.py @@ -0,0 +1,53 @@ +from typing import List, Optional, Union + +from lnbits.helpers import urlsafe_short_hash + +from . import db +from .models import CreateTempData, Temp, TempClean, LNURLCharge +from loguru import logger + + +async def create_temp(wallet_id: str, data: CreateTempData) -> Temp: + temp_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO temp.temp (id, wallet, name, total) + VALUES (?, ?, ?, ?) + """, + ( + temp_id, + wallet_id, + data.name, + data.total + ), + ) + temp = await get_temp(temp_id) + assert temp, "Newly created temp couldn't be retrieved" + return temp + + +async def get_temp(temp_id: str) -> Optional[Temp]: + row = await db.fetchone("SELECT * FROM temp.temp WHERE id = ?", (temp_id,)) + return Temp(**row) if row else None + +async def get_temps(wallet_ids: Union[str, List[str]]) -> List[Temp]: + if isinstance(wallet_ids, str): + wallet_ids = [wallet_ids] + + q = ",".join(["?"] * len(wallet_ids)) + rows = await db.fetchall( + f"SELECT * FROM temp.temp WHERE wallet IN ({q})", (*wallet_ids,) + ) + return [Temp(**row) for row in rows] + +async def update_temp(temp_id: str, **kwargs) -> Temp: + q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) + await db.execute( + f"UPDATE temp.temp SET {q} WHERE id = ?", (*kwargs.values(), temp_id) + ) + temp = await get_temp(temp_id) + assert temp, "Newly updated temp couldn't be retrieved" + return temp + +async def delete_temp(temp_id: str) -> None: + await db.execute("DELETE FROM temp.temp WHERE id = ?", (temp_id,)) \ No newline at end of file diff --git a/lnurl.py b/lnurl.py new file mode 100644 index 0000000..a9a7b82 --- /dev/null +++ b/lnurl.py @@ -0,0 +1 @@ +# Maybe your extensions needs some LNURL stuff, for anything LNURL always make sure you are on spec https://github.com/lnurl/luds \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b31672d --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "repos": [ + { + "id": "temp", + "organisation": "lnbits", + "repository": "temp" + } + ] +} diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..7214e62 --- /dev/null +++ b/migrations.py @@ -0,0 +1,28 @@ +# The migration file is like a blockchain, never edit only add! + +async def m001_initial(db): + """ + Initial templates table. + """ + await db.execute( + """ + CREATE TABLE temp.temp ( + id TEXT PRIMARY KEY, + wallet TEXT NOT NULL, + name TEXT NOT NULL + ); + """ + ) + + +# Here we are adding an extra field to the database + +async def m002_addtip_wallet(db): + """ + Add total to templates table + """ + await db.execute( + """ + ALTER TABLE temp.temp ADD total withdrawlimit INTEGER DEFAULT 0,; + """ + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..3195103 --- /dev/null +++ b/models.py @@ -0,0 +1,24 @@ +from sqlite3 import Row +from typing import Optional, List +from pydantic import BaseModel + + +class CreateTempData(BaseModel): + wallet: Optional[str] + name: Optional[str] + total: Optional[int] + +class Temp(BaseModel): + id: str + wallet: str + name: str + total: Optional[int] + + @classmethod + def from_row(cls, row: Row) -> "Temp": + return cls(**dict(row)) + +class CreateUpdateItemData(BaseModel): + items: List[Item] + +# add something lnurly \ No newline at end of file diff --git a/static/image/template.png b/static/image/template.png new file mode 100644 index 0000000..24aa00d Binary files /dev/null and b/static/image/template.png differ diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..214bcd1 --- /dev/null +++ b/tasks.py @@ -0,0 +1,59 @@ +import asyncio + +from loguru import logger + +from lnbits.core.models import Payment +from lnbits.core.services import create_invoice, pay_invoice, websocketUpdater +from lnbits.helpers import get_current_extension_name +from lnbits.tasks import register_invoice_listener + +from .crud import get_temp + + +####################################### +########## RUN YOU TASKS HERE ######### +####################################### + + +# the usual task is to listen to invoices related to this extension + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue, get_current_extension_name()) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +# do somethhing when an invoice related top this extension is paid + +async def on_invoice_paid(payment: Payment) -> None: + if payment.extra.get("tag") != "temp": + return + + temp_id = payment.extra.get("tempId") + assert temp_id + + temp = await get_temp(temp_id) + assert temp + + # update something + + data_to_update = { + "total" temp.total + payment.amount + } + + await update_temp(temp_id=temp_id, **data_to_update.dict()) + + + # here we could send some data to a websocket on wss:///api/v1/ws/ + + some_payment_data = { + "name": temp.name, + "amount": payment.amount, + "fee": payment.fee, + "checking_id": payment.checking_id + } + + await websocketUpdater(temp_id, str(some_payment_data)) diff --git a/templates/tpos/_api_docs.html b/templates/tpos/_api_docs.html new file mode 100644 index 0000000..abe4fc0 --- /dev/null +++ b/templates/tpos/_api_docs.html @@ -0,0 +1,79 @@ + + + + + + GET /temp/api/v1/temps +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<temp_object>, ...] +
Curl example
+ curl -X GET {{ request.base_url }}temp/api/v1/temps -H "X-Api-Key: + <invoice_key>" + +
+
+
+ + + + POST /temp/api/v1/temps +
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 }}temp/api/v1/temps -d '{"name": + <string>, "currency": <string>}' -H "Content-type: + application/json" -H "X-Api-Key: <admin_key>" + +
+
+
+ + + + + DELETE + /temp/api/v1/temps/<temp_id> +
Headers
+ {"X-Api-Key": <admin_key>}
+
Returns 204 NO CONTENT
+ +
Curl example
+ curl -X DELETE {{ request.base_url + }}temp/api/v1/temps/<temp_id> -H "X-Api-Key: <admin_key>" + +
+
+
+
diff --git a/templates/tpos/_tpos.html b/templates/tpos/_tpos.html new file mode 100644 index 0000000..9157728 --- /dev/null +++ b/templates/tpos/_tpos.html @@ -0,0 +1,21 @@ + + + +

+ Thiago's Point of Sale is a secure, mobile-ready, instant and shareable + point of sale terminal (PoS) for merchants. The PoS is linked to your + LNbits wallet but completely air-gapped so users can ONLY create + invoices. To share the Temp hit the hash on the terminal. +

+ Created by + Tiago Vasconcelos. +
+
+
diff --git a/templates/tpos/index.html b/templates/tpos/index.html new file mode 100644 index 0000000..ffde185 --- /dev/null +++ b/templates/tpos/index.html @@ -0,0 +1,1150 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + New Temp + + + + + +
+
+
Temp
+
+
+ Export to CSV +
+
+ + + + + + + ${ col.label } + + Tip Wallet + Tip Options % + Withdraw PIN + Withdraw Limit + Withdraw Premium + + + + + + + + + + + + PoS QR + Open PoS + + + Click to copy${props.row.id.substring(0,6)}... + + + + ${ (col.name == 'tip_options' && col.value ? + JSON.parse(col.value).join(", ") : col.value) } + + + Click to copy${props.row.tip_wallet.substring(0,6)}... + + N/A + + ${props.row.tip_options} + + N/A + + ${props.row.withdrawpin} + + N/A + + ${props.row.withdrawlimit} + + N/A + + ${props.row.withdrawpremium}% + + 0 + + + + + + + + + +
+
+ + + + + +
+
+
+ Add Item + Delete All + + + + + + Import + Import a JSON file + + + + + + Export + Export a JSON file + + + + +
+
+ +
+
+ + + + + + + + + + + + ${props.row.id.split(':')[1]} + + + ${props.row.title} + + + ${itemFormatPrice(props.row.price, + props.row.id)} + + + ${props.row.disabled} + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
{{SITE_TITLE}} Temp extension
+
+ + + + {% include "temp/_api_docs.html" %} + + {% include "temp/_temp.html" %} + + +
+
+ + + + + + + +
+
+ +
+
+ +
+
+ + + Hit enter to add values + + You can leave this blank. A default rounding option is available + (round amount to a value) + + + + + + + + + + +
+ Update Temp + Create Temp + Cancel +
+
+
+
+ + + + + + + + + +
+ ${itemDialog.data.id ? 'Update Item' : 'Create Item'} + Cancel +
+
+
+
+ + + + + +
+

+ ${ urlDialog.data.name }
${ + urlDialog.data.shareUrl } +

+
+
+ Copy URL + Close +
+
+
+ + + +
+ Importing ${fileDataDialog?.count} items +
+
+ + + + + + + + + + ${item.title} + ${item.description} + + + + + + + +
+ Import + Close +
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/templates/tpos/tpos.html b/templates/tpos/tpos.html new file mode 100644 index 0000000..8c4a56f --- /dev/null +++ b/templates/tpos/tpos.html @@ -0,0 +1,1205 @@ +{% extends "public.html" %} {% block toolbar_title %} {{ temp.name }} + + +{% endblock %} {% block footer %}{% endblock %} {% block page_container %} + + + +
+
+

${ amountFormatted }

+
${ fsat } sat
+
+
+ Total: ${totalFormatted}
${totalfsat} sat
+
+
+
+
+
+ + +
+
+
+ 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + C + 0 + + + + Ok +
+
+
+
+ + + + + + + + + + + +
+ + +
+
+

${totalFormatted}

+
${totalfsat} sat
+
+
+ + + + + + + + + + + + + + + +
+ ${item.title} + +
+ +
+ ${item.quantity} +
+ +
+
+ ${item.formattedPrice} + + +
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ + + +
+
+
+
+ + + + +
${item.title}
+
${item.formattedPrice}
+
+ ${item.description} +
+
+ + + Add + +
+
+
+ + + + ${cartDrawer ? 'Hide Cart' :' Open Cart'} +
+ EXIT ATM MODE + + + + + +
+

${ amountWithTipFormatted }

+
+ ${ fsat } + sat + ( + ${ tipAmountFormatted } tip) +
+ +
+
+ Copy invoice + Pay in wallet + Close +
+
+
+ + + +
+ Would you like to leave a tip? +
+
+ ${ tip }% + +
+ + + Ok +
+
+
+ No, thanks + Close +
+
+
+ + + + + + +
+

+ {{ temp.name }}
{{ request.url }} +

+
+
+ Copy URL + Close +
+
+
+ + + + + + + + + + + + + + + No paid invoices + + + + + ${payment.amount / 1000} sats + Hash: ${payment.checking_id.slice(0, 30)}... + + + ${payment.dateFrom} + + + + + + + + + + +
Withdraw PIN
+
+ + + + +
+ +
+
+
+
+
+
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + + +{% endblock %} diff --git a/views.py b/views.py new file mode 100644 index 0000000..8cd1fb7 --- /dev/null +++ b/views.py @@ -0,0 +1,88 @@ +from http import HTTPStatus + +from fastapi import Depends, Request +from fastapi.templating import Jinja2Temps +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists +from lnbits.settings import settings + +from . import temp_ext, temp_renderer +from .crud import get_temp + +temps = Jinja2Temps(directory="temps") + + +####################################### +##### ADD YOUR PAGE ENDPOINTS HERE #### +####################################### + + +# Backend admin page + +@temp_ext.get("/", response_class=HTMLResponse) +async def index(request: Request, user: User = Depends(check_user_exists)): + return temp_renderer().TempResponse( + "temp/index.html", {"request": request, "user": user.dict()} + ) + + +# Frontend shareable page + +@temp_ext.get("/{temp_id}") +async def temp(request: Request, temp_id): + temp = await get_temp(temp_id) + if not temp: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Temp does not exist." + ) + return temp_renderer().TempResponse( + "temp/temp.html", + { + "request": request, + "temp": temp, + "withdrawamtemps": temp.withdrawamtemps, + "web_manifest": f"/temp/manifest/{temp_id}.webmanifest", + }, + ) + + +# Customise a manifest, or remove manifest completely + +@temp_ext.get("/manifest/{temp_id}.webmanifest") +async def manifest(temp_id: str): + remp= await get_temp(temp_id) + if not temp: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Temp does not exist." + ) + + return { + "short_name": settings.lnbits_site_title, + "name": temp.name + " - " + settings.lnbits_site_title, + "icons": [ + { + "src": settings.lnbits_custom_logo + if settings.lnbits_custom_logo + else "https://cdn.jsdelivr.net/gh/lnbits/lnbits@0.3.0/docs/logos/lnbits.png", + "type": "image/png", + "sizes": "900x900", + } + ], + "start_url": "/temp/" + temp_id, + "background_color": "#1F2234", + "description": "Minimal extension to build on", + "display": "standalone", + "scope": "/temp/" + temp_id, + "theme_color": "#1F2234", + "shortcuts": [ + { + "name": temp.name + " - " + settings.lnbits_site_title, + "short_name": temp.name, + "description": temp.name + " - " + settings.lnbits_site_title, + "url": "/temp/" + temp_id, + } + ], + } diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..6dc1bc6 --- /dev/null +++ b/views_api.py @@ -0,0 +1,136 @@ +from http import HTTPStatus +import json + +import httpx +from fastapi import Depends, Query, Request +from lnurl import decode as decode_lnurl +from loguru import logger +from starlette.exceptions import HTTPException + +from lnbits.core.crud import get_user +from lnbits.core.models import Payment +from lnbits.core.services import create_invoice +from lnbits.core.views.api import api_payment +from lnbits.decorators import ( + WalletTypeInfo, + check_admin, + get_key_type, + require_admin_key, + require_invoice_key, +) + +from . import temp_ext +from .crud import ( + create_temp, + update_temp, + delete_temp, + get_temp, + get_temps +) +from .models import CreateTempData, PayLnurlWData, LNURLCharge, CreateUpdateItemData + + +####################################### +##### ADD YOUR API ENDPOINTS HERE ##### +####################################### + + +# TYPICAL ENDPOINTS + +# get all the records belonging to the user + +@temp_ext.get("/api/v1/temps", status_code=HTTPStatus.OK) +async def api_temps( + all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) +): + wallet_ids = [wallet.wallet.id] + if all_wallets: + user = await get_user(wallet.wallet.user) + wallet_ids = user.wallet_ids if user else [] + return [temp.dict() for temp in await get_temps(wallet_ids)] + + +# get a specific record belonging to a user + +@temp_ext.put("/api/v1/temps/{temp_id}") +async def api_temp_update( + data: CreateTempData, + temp_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + if not temp_id: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Temp does not exist." + ) + temp = await get_temp(temp_id) + assert temp, "Temp couldn't be retrieved" + + if wallet.wallet.id != temp.wallet: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your Temp.") + temp = await update_temp(temp_id=temp_id, **data.dict()) + return temp.dict() + + +# Create a new record + +@temp_ext.post("/api/v1/temps", status_code=HTTPStatus.CREATED) +async def api_temp_create( + data: CreateTempData, wallet: WalletTypeInfo = Depends(get_key_type) +): + temp = await create_temp(wallet_id=wallet.wallet.id, data=data) + return temp.dict() + + +# Delete a record + +@temp_ext.delete("/api/v1/temps/{temp_id}") +async def api_temp_delete( + temp_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): + temp = await get_temp(temp_id) + + if not temp: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Temp does not exist." + ) + + if temp.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your Temp.") + + await delete_temp(temp_id) + return "", HTTPStatus.NO_CONTENT + + +# ANY OTHER ENDPOINTS YOU NEED + +# This endpoint creates a payment + +@tpos_ext.post("/api/v1/temps/payment/{temp_id}", status_code=HTTPStatus.CREATED) +async def api_tpos_create_invoice( + temp_id: str, amount: int = Query(..., ge=1), memo: str = "" +) -> dict: + temp = await get_temp(temp_id) + + if not temp: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Temp does not exist." + ) + + # we create a payment and add some tags, so tasks.py can grab the payment once its paid + + try: + payment_hash, payment_request = await create_invoice( + wallet_id=temp.wallet, + amount=amount, + memo=f"{memo} to {temp.name}" if memo else f"{temp.name}", + extra={ + "tag": "temp", + "tipAmount": tipAmount, + "tempId": tempId, + "amount": amount, + }, + ) + except Exception as e: + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) + + return {"payment_hash": payment_hash, "payment_request": payment_request} \ No newline at end of file