diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index 5b0d572c5..f9321056c 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -131,14 +131,15 @@ async def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wa # --------------- -async def get_standalone_payment(checking_id: str) -> Optional[Payment]: +async def get_standalone_payment(checking_id_or_hash: str) -> Optional[Payment]: row = await db.fetchone( """ SELECT * FROM apipayments - WHERE checking_id = ? + WHERE checking_id = ? OR payment_hash = ? + LIMIT 1 """, - (checking_id,), + (checking_id_or_hash, checking_id_or_hash), ) return Payment.from_row(row) if row else None @@ -285,11 +286,7 @@ async def create_payment( async def update_payment_status(checking_id: str, pending: bool) -> None: await db.execute( - "UPDATE apipayments SET pending = ? WHERE checking_id = ?", - ( - int(pending), - checking_id, - ), + "UPDATE apipayments SET pending = ? WHERE checking_id = ?", (int(pending), checking_id,), ) diff --git a/lnbits/extensions/offlineshop/README.md b/lnbits/extensions/offlineshop/README.md new file mode 100644 index 000000000..254bc6884 --- /dev/null +++ b/lnbits/extensions/offlineshop/README.md @@ -0,0 +1 @@ +# Offline Shop diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py new file mode 100644 index 000000000..e24fa14b5 --- /dev/null +++ b/lnbits/extensions/offlineshop/__init__.py @@ -0,0 +1,12 @@ +from quart import Blueprint + +from lnbits.db import Database + +db = Database("ext_offlineshop") + +offlineshop_ext: Blueprint = Blueprint("offlineshop", __name__, static_folder="static", template_folder="templates") + + +from .views_api import * # noqa +from .views import * # noqa +from .lnurl import * # noqa diff --git a/lnbits/extensions/offlineshop/config.json b/lnbits/extensions/offlineshop/config.json new file mode 100644 index 000000000..3748c9f45 --- /dev/null +++ b/lnbits/extensions/offlineshop/config.json @@ -0,0 +1,8 @@ +{ + "name": "Offline Shop", + "short_description": "Sell stuff with Lightning and lnurlpay on a shop without internet or any electronic device.", + "icon": "nature_people", + "contributors": [ + "fiatjaf" + ] +} diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py new file mode 100644 index 000000000..0544edeaa --- /dev/null +++ b/lnbits/extensions/offlineshop/crud.py @@ -0,0 +1,80 @@ +from typing import List, Optional + +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) + VALUES (?, ?) + """, + (wallet_id, "\n".join(animals)), + ) + return result._result_proxy.lastrowid + + +async def get_shop(id: int) -> Optional[Shop]: + row = await db.fetchone("SELECT * FROM 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,)) + + if not row: + # create on the fly + ls_id = await create_shop(wallet_id=wallet) + return await get_shop(ls_id) + + return Shop(**dict(row)) if row else None + + +async def add_item(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) + VALUES (?, ?, ?, ?, ?, ?) + """, + (shop, name, description, image, price, unit), + ) + return result._result_proxy.lastrowid + + +async def update_item( + shop: int, item_id: int, name: str, description: str, image: Optional[str], price: int, unit: str, +) -> int: + await db.execute( + """ + UPDATE items SET + name = ?, + description = ?, + image = ?, + price = ?, + unit = ? + WHERE shop = ? AND id = ? + """, + (name, description, image, price, unit, shop, item_id), + ) + return item_id + + +async def get_item(id: int) -> Optional[Item]: + row = await db.fetchone("SELECT * FROM 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,)) + 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 = ? + """, + (shop, item_id), + ) diff --git a/lnbits/extensions/offlineshop/helpers.py b/lnbits/extensions/offlineshop/helpers.py new file mode 100644 index 000000000..13e748574 --- /dev/null +++ b/lnbits/extensions/offlineshop/helpers.py @@ -0,0 +1,48 @@ +import trio # type: ignore +import httpx + + +async def get_fiat_rate(currency: str): + assert currency == "USD", "Only USD is supported as fiat currency." + return await get_usd_rate() + + +async def get_usd_rate(): + """ + Returns an average satoshi price from multiple sources. + """ + + satoshi_prices = [None, None, None] + + async def fetch_price(index, url, getter): + try: + async with httpx.AsyncClient() as client: + r = await client.get(url) + r.raise_for_status() + satoshi_price = int(100_000_000 / float(getter(r.json()))) + satoshi_prices[index] = satoshi_price + except Exception: + pass + + async with trio.open_nursery() as nursery: + nursery.start_soon( + fetch_price, + 0, + "https://api.kraken.com/0/public/Ticker?pair=XXBTZUSD", + lambda d: d["result"]["XXBTCZUSD"]["c"][0], + ) + nursery.start_soon( + fetch_price, + 1, + "https://www.bitstamp.net/api/v2/ticker/btcusd", + lambda d: d["last"], + ) + nursery.start_soon( + fetch_price, + 2, + "https://api.coincap.io/v2/rates/bitcoin", + lambda d: d["data"]["rateUsd"], + ) + + satoshi_prices = [x for x in satoshi_prices if x] + return sum(satoshi_prices) / len(satoshi_prices) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py new file mode 100644 index 000000000..780593ced --- /dev/null +++ b/lnbits/extensions/offlineshop/lnurl.py @@ -0,0 +1,63 @@ +import hashlib +from quart import jsonify, url_for, request +from lnurl import LnurlPayResponse, LnurlPayActionResponse, LnurlErrorResponse # type: ignore + +from lnbits.core.services import create_invoice + +from . import offlineshop_ext +from .crud import get_shop, get_item +from .helpers import get_fiat_rate + + +@offlineshop_ext.route("/lnurl/", methods=["GET"]) +async def lnurl_response(item_id): + item = await get_item(item_id) + if not item: + return jsonify({"status": "ERROR", "reason": "Item not found."}) + + rate = await get_fiat_rate(item.unit) if item.unit != "sat" else 1 + price_msat = item.price * 1000 * rate + + resp = LnurlPayResponse( + callback=url_for("shop.lnurl_callback", item_id=item.id, _external=True), + min_sendable=price_msat, + max_sendable=price_msat, + metadata=await item.lnurlpay_metadata(), + ) + + return jsonify(resp.dict()) + + +@offlineshop_ext.route("/lnurl/cb/", methods=["GET"]) +async def lnurl_callback(item_id): + item = await get_item(item_id) + if not item: + return jsonify({"status": "ERROR", "reason": "Couldn't find item."}) + + if item.unit == "sat": + min = item.price * 1000 + max = item.price * 1000 + else: + rate = await get_fiat_rate(item.unit) + # allow some fluctuation (the fiat price may have changed between the calls) + min = rate * 995 * item.price + max = rate * 1010 * item.price + + amount_received = int(request.args.get("amount")) + if amount_received < min: + return jsonify(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()) + + shop = await get_shop(item.shop) + payment_hash, payment_request = await create_invoice( + wallet_id=shop.wallet, + amount=int(amount_received / 1000), + memo=await item.name, + description_hash=hashlib.sha256((await item.lnurlpay_metadata()).encode("utf-8")).digest(), + extra={"tag": "offlineshop", "item": item.id}, + ) + + resp = LnurlPayActionResponse(pr=payment_request, success_action=item.success_action(payment_hash, shop), routes=[]) + + return jsonify(resp.dict()) diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py new file mode 100644 index 000000000..72e9e501d --- /dev/null +++ b/lnbits/extensions/offlineshop/migrations.py @@ -0,0 +1,28 @@ +async def m001_initial(db): + """ + Initial offlineshop tables. + """ + await db.execute( + """ + CREATE TABLE shops ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet TEXT NOT NULL, + wordlist TEXT + ); + """ + ) + + await db.execute( + """ + CREATE TABLE items ( + shop INTEGER NOT NULL REFERENCES shop (id), + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT NOT NULL, + image TEXT, -- image/png;base64,... + enabled BOOLEAN NOT NULL DEFAULT true, + price INTEGER NOT NULL, + unit TEXT NOT NULL DEFAULT 'sat' + ); + """ + ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py new file mode 100644 index 000000000..5a0dcee11 --- /dev/null +++ b/lnbits/extensions/offlineshop/models.py @@ -0,0 +1,65 @@ +import json +from collections import OrderedDict +from quart import url_for +from typing import NamedTuple, Optional +from lnurl import encode as lnurl_encode # type: ignore +from lnurl.types import LnurlPayMetadata # type: ignore +from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore + + +class Shop(NamedTuple): + id: int + wallet: str + wordlist: str + + def get_word(self, payment_hash): + # initialize confirmation words cache + self.fulfilled_payments = self.words or OrderedDict() + + if payment_hash in self.fulfilled_payments: + return self.fulfilled_payments[payment_hash] + + # get a new word + self.counter = (self.counter or -1) + 1 + wordlist = self.wordlist.split("\n") + word = [self.counter % len(wordlist)] + + # cleanup confirmation words cache + to_remove = self.fulfilled_payments - 23 + if to_remove > 0: + for i in range(to_remove): + self.fulfilled_payments.popitem(False) + + return word + + +class Item(NamedTuple): + shop: int + id: int + name: str + description: str + image: str + enabled: bool + price: int + unit: str + + @property + def lnurl(self) -> str: + return lnurl_encode(url_for("offlineshop.lnurl_response", item_id=self.id)) + + async def lnurlpay_metadata(self) -> LnurlPayMetadata: + metadata = [["text/plain", self.description]] + + if self.image: + metadata.append(self.image.split(",")) + + return LnurlPayMetadata(json.dumps(metadata)) + + def success_action(self, shop: Shop, payment_hash: str) -> Optional[LnurlPaySuccessAction]: + if not self.wordlist: + return None + + return UrlAction( + url=url_for("offlineshop.confirmation_code", p=payment_hash, _external=True), + description="Open to get the confirmation code for your purchase.", + ) diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js new file mode 100644 index 000000000..7cbdf3052 --- /dev/null +++ b/lnbits/extensions/offlineshop/static/js/index.js @@ -0,0 +1,157 @@ +/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ + +Vue.component(VueQrcode.name, VueQrcode) + +const pica = window.pica() + +const defaultItemData = { + unit: 'sat' +} + +new Vue({ + el: '#vue', + mixins: [windowMixin], + data() { + return { + selectedWallet: null, + offlineshop: { + wordlist: [], + items: [] + }, + itemDialog: { + show: false, + data: {...defaultItemData}, + units: ['sat', 'USD'] + } + } + }, + methods: { + async imageAdded(file) { + let image = new Image() + image.src = URL.createObjectURL(file) + let canvas = document.getElementById('uploading-image') + image.onload = async () => { + canvas.setAttribute('width', 300) + canvas.setAttribute('height', 300) + await pica.resize(image, canvas) + this.itemDialog.data.image = canvas.toDataURL() + } + }, + imageCleared() { + this.itemDialog.data.image = null + let canvas = document.getElementById('uploading-image') + canvas.setAttribute('height', 0) + canvas.setAttribute('width', 0) + let ctx = canvas.getContext('2d') + ctx.clearRect(0, 0, canvas.width, canvas.height) + }, + disabledAddItemButton() { + return ( + !this.itemDialog.data.name || + this.itemDialog.data.name.length === 0 || + !this.itemDialog.data.price || + !this.itemDialog.data.description || + !this.itemDialog.data.unit || + this.itemDialog.data.unit.length === 0 + ) + }, + changedWallet(wallet) { + this.selectedWallet = wallet + this.loadShop() + }, + loadShop() { + LNbits.api + .request( + 'GET', + '/offlineshop/api/v1/offlineshop', + this.selectedWallet.inkey + ) + .then(response => { + this.offlineshop = response.data + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + addItem() { + let {name, image, description, price, unit} = this.itemDialog.data + + LNbits.api + .request( + 'POST', + '/offlineshop/api/v1/offlineshop/items', + this.selectedWallet.inkey, + { + name, + description, + image, + price, + unit + } + ) + .then(response => { + this.$q.notify({ + message: `Item '${this.itemDialog.data.name}' added.`, + timeout: 700 + }) + this.loadShop() + this.itemsDialog.show = false + this.itemsDialog.data = {...defaultItemData} + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + toggleItem(itemId) { + let item = this.offlineshop.items.find(item => item.id === itemId) + item.enabled = !item.enabled + + LNbits.api + .request( + 'PUT', + '/offlineshop/api/v1/offlineshop/items/' + itemId, + this.selectedWallet.inkey, + item + ) + .then(response => { + this.$q.notify({ + message: `Item ${item.enabled ? 'enabled' : 'disabled'}.`, + timeout: 700 + }) + this.offlineshop.items = this.offlineshop.items + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }, + deleteItem(itemId) { + LNbits.utils + .confirmDialog('Are you sure you want to delete this item?') + .onOk(() => { + LNbits.api + .request( + 'DELETE', + '/offlineshop/api/v1/offlineshop/items/' + itemId, + this.selectedWallet.inkey + ) + .then(response => { + this.$q.notify({ + message: `Item deleted.`, + timeout: 700 + }) + this.offlineshop.items.splice( + this.offlineshop.items.findIndex(item => item.id === itemId), + 1 + ) + }) + .catch(err => { + LNbits.utils.notifyApiError(err) + }) + }) + } + }, + created() { + this.selectedWallet = this.g.user.wallets[0] + this.loadShop() + } +}) diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html new file mode 100644 index 000000000..944c746a0 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html @@ -0,0 +1,46 @@ + + + +

+ Sell stuff offline, accept Lightning payments. Powered by LNURL-pay. +

+
+
+
+ + + + + + GET +
Headers
+ {"X-Api-Key": <invoice_key>}
+
Body (application/json)
+
+ Returns 200 OK (application/json) +
+ [<offlineshop object>, ...] +
Curl example
+ curl -X GET {{ request.url_root }}/offlineshop/api/v1/offlineshop -H + "X-Api-Key: {{ g.user.wallets[0].inkey }}" + +
+
+
+
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html new file mode 100644 index 000000000..3f75f8d28 --- /dev/null +++ b/lnbits/extensions/offlineshop/templates/offlineshop/index.html @@ -0,0 +1,195 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + +
+
+
Items
+
+
+ Add new item +
+
+ {% raw %} + + + + + {% endraw %} +
+
+ + + + + + + + + +
+ +
+ + +
LNbits OfflineShop extension
+
+ + + {% include "offlineshop/_api_docs.html" %} + +
+
+ + + + + + + + + + + + + + +
+
+ Add item +
+
+ Cancel +
+
+
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + + +{% endblock %} diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py new file mode 100644 index 000000000..140c1a766 --- /dev/null +++ b/lnbits/extensions/offlineshop/views.py @@ -0,0 +1,34 @@ +import time +from quart import g, render_template, request +from http import HTTPStatus + +from lnbits.decorators import check_user_exists, validate_uuids +from lnbits.core.models import Payment +from lnbits.core.crud import get_standalone_payment + +from . import offlineshop_ext +from .crud import get_item, get_shop + + +@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.route("/confirmation") +async def confirmation_code(): + payment_hash = request.args.get("p") + payment: Payment = await get_standalone_payment(payment_hash) + if not payment: + return f"Couldn't find the payment {payment_hash}.", HTTPStatus.NOT_FOUND + if payment.pending: + return f"Payment {payment_hash} wasn't received yet. Please try again in a minute.", HTTPStatus.PAYMENT_REQUIRED + + if payment.time + 60 * 15 < time.time(): + return "too much time has passed." + + item = await get_item(payment.extra.get("item")) + shop = await get_shop(item.shop) + return shop.next_word(payment_hash) diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py new file mode 100644 index 000000000..749eec427 --- /dev/null +++ b/lnbits/extensions/offlineshop/views_api.py @@ -0,0 +1,72 @@ +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 . import offlineshop_ext +from .crud import ( + get_or_create_shop_by_wallet, + add_item, + update_item, + get_items, + delete_item_from_shop, +) + + +@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) + items = await get_items(shop.id) + + try: + return ( + jsonify({**shop._asdict(), **{"items": [item._asdict() for item in items],},}), + HTTPStatus.OK, + ) + except LnurlInvalidUrl: + return ( + jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}), + HTTPStatus.UPGRADE_REQUIRED, + ) + + +@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}, + "price": {"type": "number", "required": True}, + "unit": {"type": "string", "allowed": ["sat", "USD"], "required": True}, + } +) +async def api_add_or_update_item(item_id=None): + shop = await get_or_create_shop_by_wallet(g.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"], + ) + return "", 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"], + ) + 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) + await delete_item_from_shop(shop.id, item_id) + return "", HTTPStatus.NO_CONTENT diff --git a/lnbits/extensions/offlineshop/wordlists.py b/lnbits/extensions/offlineshop/wordlists.py new file mode 100644 index 000000000..ee3663e34 --- /dev/null +++ b/lnbits/extensions/offlineshop/wordlists.py @@ -0,0 +1,28 @@ +animals = [ + "albatross", + "bison", + "chicken", + "duck", + "eagle", + "flamingo", + "gorila", + "hamster", + "iguana", + "jaguar", + "koala", + "llama", + "macaroni penguim", + "numbat", + "octopus", + "platypus", + "quetzal", + "rabbit", + "salmon", + "tuna", + "unicorn", + "vulture", + "wolf", + "xenops", + "yak", + "zebra", +]