diff --git a/lnbits/extensions/offlineshop/README.md b/lnbits/extensions/offlineshop/README.md deleted file mode 100644 index 7b9c6c8d..00000000 --- a/lnbits/extensions/offlineshop/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Offline Shop - -## Create QR codes for each product and display them on your store for receiving payments Offline - -[![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop') - -LNbits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device. - -Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a customer chooses an item, scans the QR code, gets the description and price. After payment, the customer gets a confirmation code that the merchant can validate to be sure the payment was successful. - -Customers must use an LNURL pay capable wallet. - -[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) - -## Usage - -1. Entering the Offline shop extension you'll see an Items list, the Shop wallet and a Wordslist\ - ![offline shop back office](https://i.imgur.com/Ei7cxj9.png) -2. Begin by creating an item, click "ADD NEW ITEM" - - set the item name and a small description - - you can set an optional, preferably square image, that will show up on the customer wallet - _depending on wallet_ - - set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time customer scans to pay\ - ![add new item](https://i.imgur.com/pkZqRgj.png) -3. After creating some products, click on "PRINT QR CODES"\ - ![print qr codes](https://i.imgur.com/2GAiSTe.png) -4. You'll see a QR code for each product in your LNbits Offline Shop with a title and price ready for printing\ - ![qr codes sheet](https://i.imgur.com/faEqOcd.png) -5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet -6. Choose what type of confirmation do you want customers to report to merchant after a successful payment\ - ![wordlist](https://i.imgur.com/9aM6NUL.png) - - - Wordlist is the default option: after a successful payment the customer will receive a word from this list, **sequentially**. Starting in _albatross_ as customers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\ - ![totp authenticator](https://i.imgur.com/MrJXFxz.png) - - TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\ - ![disable confirmations](https://i.imgur.com/2OFs4yi.png) - - Nothing, disables the need for confirmation of payment, click the "DISABLE CONFIRMATION CODES" diff --git a/lnbits/extensions/offlineshop/__init__.py b/lnbits/extensions/offlineshop/__init__.py deleted file mode 100644 index 72d1ae6b..00000000 --- a/lnbits/extensions/offlineshop/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -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_static_files = [ - { - "path": "/offlineshop/static", - "app": StaticFiles(packages=[("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 .lnurl import * # noqa: F401,F403 -from .views import * # noqa: F401,F403 -from .views_api import * # noqa: F401,F403 diff --git a/lnbits/extensions/offlineshop/config.json b/lnbits/extensions/offlineshop/config.json deleted file mode 100644 index 94dcd478..00000000 --- a/lnbits/extensions/offlineshop/config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "OfflineShop", - "short_description": "Receive payments for products offline!", - "tile": "/offlineshop/static/image/offlineshop.png", - "contributors": [ - "fiatjaf" - ] -} diff --git a/lnbits/extensions/offlineshop/crud.py b/lnbits/extensions/offlineshop/crud.py deleted file mode 100644 index 1fa63f3e..00000000 --- a/lnbits/extensions/offlineshop/crud.py +++ /dev/null @@ -1,117 +0,0 @@ -from typing import List, Optional - -from lnbits.db import SQLITE - -from . import db -from .models import Item, Shop -from .wordlists import animals - - -async def create_shop(*, wallet_id: str) -> int: - 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)), - ) - if db.type == SQLITE: - return result._result_proxy.lastrowid - else: - return result[0] # type: ignore - - -async def get_shop(id: int) -> Optional[Shop]: - row = await db.fetchone("SELECT * FROM offlineshop.shops WHERE id = ?", (id,)) - return Shop(**row) if row else None - - -async def get_or_create_shop_by_wallet(wallet: str) -> Optional[Shop]: - row = await db.fetchone( - "SELECT * FROM offlineshop.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(**row) if row else None - - -async def set_method(shop: int, method: str, wordlist: str = "") -> Optional[Shop]: - await db.execute( - "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, - fiat_base_multiplier: int, -) -> int: - result = await db.execute( - """ - INSERT INTO offlineshop.items (shop, name, description, image, price, unit, fiat_base_multiplier) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - (shop, name, description, image, price, unit, fiat_base_multiplier), - ) - 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, - fiat_base_multiplier: int, -) -> int: - await db.execute( - """ - UPDATE offlineshop.items SET - name = ?, - description = ?, - image = ?, - price = ?, - unit = ?, - fiat_base_multiplier = ? - WHERE shop = ? AND id = ? - """, - (name, description, image, price, unit, fiat_base_multiplier, shop, item_id), - ) - return item_id - - -async def get_item(id: int) -> Optional[Item]: - row = await db.fetchone( - "SELECT * FROM offlineshop.items WHERE id = ? LIMIT 1", (id,) - ) - return Item.from_row(row) if row else None - - -async def get_items(shop: int) -> List[Item]: - rows = await db.fetchall("SELECT * FROM offlineshop.items WHERE shop = ?", (shop,)) - return [Item.from_row(row) for row in rows] - - -async def delete_item_from_shop(shop: int, item_id: int): - await db.execute( - """ - 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 deleted file mode 100644 index 86a653aa..00000000 --- a/lnbits/extensions/offlineshop/helpers.py +++ /dev/null @@ -1,17 +0,0 @@ -import base64 -import hmac -import struct -import time - - -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 - return str(binary)[-digits:].zfill(digits) - - -def totp(key, time_step=30, digits=6, digest="sha1"): - return hotp(key, int(time.time() / time_step), digits, digest) diff --git a/lnbits/extensions/offlineshop/lnurl.py b/lnbits/extensions/offlineshop/lnurl.py deleted file mode 100644 index ca4e6bac..00000000 --- a/lnbits/extensions/offlineshop/lnurl.py +++ /dev/null @@ -1,88 +0,0 @@ -from fastapi import Query -from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse -from lnurl.models import ClearnetUrl, LightningInvoice, MilliSatoshi -from starlette.requests import Request - -from lnbits.core.services import create_invoice -from lnbits.utils.exchange_rates import fiat_amount_as_satoshis - -from . import offlineshop_ext -from .crud import get_item, get_shop - - -@offlineshop_ext.get("/lnurl/{item_id}", name="offlineshop.lnurl_response") -async def lnurl_response(req: Request, item_id: int = Query(...)) -> dict: - item = await get_item(item_id) - if not item: - return {"status": "ERROR", "reason": "Item not found."} - - if not item.enabled: - return {"status": "ERROR", "reason": "Item disabled."} - - price_msat = ( - await fiat_amount_as_satoshis(item.price, item.unit) - if item.unit != "sat" - else item.price - ) * 1000 - - resp = LnurlPayResponse( - callback=ClearnetUrl( - req.url_for("offlineshop.lnurl_callback", item_id=item.id), scheme="https" - ), - minSendable=MilliSatoshi(price_msat), - maxSendable=MilliSatoshi(price_msat), - metadata=await item.lnurlpay_metadata(), - ) - - return resp.dict() - - -@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) - if not item: - return {"status": "ERROR", "reason": "Couldn't find item."} - - if item.unit == "sat": - min = item.price * 1000 - max = item.price * 1000 - else: - price = await fiat_amount_as_satoshis(item.price, item.unit) - # allow some fluctuation (the fiat price may have changed between the calls) - min = price * 995 - max = price * 1010 - - amount_received = int(request.query_params.get("amount") or 0) - if amount_received < min: - return LnurlErrorResponse( - reason=f"Amount {amount_received} is smaller than minimum {min}." - ).dict() - elif amount_received > max: - return LnurlErrorResponse( - reason=f"Amount {amount_received} is greater than maximum {max}." - ).dict() - - shop = await get_shop(item.shop) - assert shop - - try: - payment_hash, payment_request = await create_invoice( - wallet_id=shop.wallet, - amount=int(amount_received / 1000), - memo=item.name, - unhashed_description=(await item.lnurlpay_metadata()).encode(), - extra={"tag": "offlineshop", "item": item.id}, - ) - except Exception as exc: - return LnurlErrorResponse(reason=str(exc)).dict() - - if shop.method: - success_action = item.success_action(shop, payment_hash, request) - assert success_action - resp = LnurlPayActionResponse( - pr=LightningInvoice(payment_request), - successAction=success_action, - routes=[], - ) - - return resp.dict() diff --git a/lnbits/extensions/offlineshop/migrations.py b/lnbits/extensions/offlineshop/migrations.py deleted file mode 100644 index 4e668668..00000000 --- a/lnbits/extensions/offlineshop/migrations.py +++ /dev/null @@ -1,39 +0,0 @@ -async def m001_initial(db): - """ - Initial offlineshop tables. - """ - await db.execute( - f""" - CREATE TABLE offlineshop.shops ( - id {db.serial_primary_key}, - wallet TEXT NOT NULL, - method TEXT NOT NULL, - wordlist TEXT - ); - """ - ) - - await db.execute( - 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,... - enabled BOOLEAN NOT NULL DEFAULT true, - price {db.big_int} NOT NULL, - unit TEXT NOT NULL DEFAULT 'sat' - ); - """ - ) - - -async def m002_fiat_base_multiplier(db): - """ - Store the multiplier for fiat prices. We store the price in cents and - remember to multiply by 100 when we use it to convert to Dollars. - """ - await db.execute( - "ALTER TABLE offlineshop.items ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;" - ) diff --git a/lnbits/extensions/offlineshop/models.py b/lnbits/extensions/offlineshop/models.py deleted file mode 100644 index 01044cb0..00000000 --- a/lnbits/extensions/offlineshop/models.py +++ /dev/null @@ -1,138 +0,0 @@ -import base64 -import hashlib -import json -from collections import OrderedDict -from sqlite3 import Row -from typing import Dict, List, Optional - -from lnurl import encode as lnurl_encode -from lnurl.models import ClearnetUrl, Max144Str, UrlAction -from lnurl.types import LnurlPayMetadata -from pydantic import BaseModel -from starlette.requests import Request - -from .helpers import totp - -shop_counters: Dict = {} - - -class ShopCounter: - wordlist: List[str] - fulfilled_payments: OrderedDict - counter: int - - @classmethod - def invoke(cls, shop: "Shop"): - shop_counter = shop_counters.get(shop.id) - if not shop_counter: - shop_counter = cls(wordlist=shop.wordlist.split("\n")) - shop_counters[shop.id] = shop_counter - return shop_counter - - @classmethod - def reset(cls, shop: "Shop"): - shop_counter = cls.invoke(shop) - shop_counter.counter = -1 - shop_counter.wordlist = shop.wordlist.split("\n") - - def __init__(self, wordlist: List[str]): - self.wordlist = wordlist - self.fulfilled_payments = OrderedDict() - self.counter = -1 - - def get_word(self, payment_hash): - if payment_hash in self.fulfilled_payments: - return self.fulfilled_payments[payment_hash] - - # get a new word - self.counter += 1 - word = self.wordlist[self.counter % len(self.wordlist)] - self.fulfilled_payments[payment_hash] = word - - # cleanup confirmation words cache - to_remove = len(self.fulfilled_payments) - 23 - if to_remove > 0: - for _ in range(to_remove): - self.fulfilled_payments.popitem(False) - - return word - - -class Shop(BaseModel): - id: int - wallet: str - method: str - wordlist: str - - @classmethod - def from_row(cls, row: Row): - return cls(**dict(row)) - - @property - def otp_key(self) -> str: - return base64.b32encode( - hashlib.sha256( - ("otpkey" + str(self.id) + self.wallet).encode("ascii") - ).digest() - ).decode("ascii") - - def get_code(self, payment_hash: str) -> str: - if self.method == "wordlist": - sc = ShopCounter.invoke(self) - return sc.get_word(payment_hash) - elif self.method == "totp": - return totp(self.otp_key) - return "" - - -class Item(BaseModel): - shop: int - id: int - name: str - description: str - image: Optional[str] - enabled: bool - price: float - unit: str - fiat_base_multiplier: int - - @classmethod - def from_row(cls, row: Row) -> "Item": - data = dict(row) - if data["unit"] != "sat" and data["fiat_base_multiplier"]: - data["price"] /= data["fiat_base_multiplier"] - return cls(**data) - - def lnurl(self, req: Request) -> str: - return lnurl_encode(req.url_for("offlineshop.lnurl_response", item_id=self.id)) - - 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: - metadata = [["text/plain", self.description]] - - if self.image: - metadata.append(self.image.split(":")[1].split(",")) - - return LnurlPayMetadata(json.dumps(metadata)) - - def success_action( - self, shop: Shop, payment_hash: str, req: Request - ) -> Optional[UrlAction]: - if not shop.wordlist: - return None - - return UrlAction( - url=ClearnetUrl( - req.url_for("offlineshop.confirmation_code", p=payment_hash), - scheme="https", - ), - description=Max144Str( - "Open to get the confirmation code for your purchase." - ), - ) diff --git a/lnbits/extensions/offlineshop/static/image/offlineshop.png b/lnbits/extensions/offlineshop/static/image/offlineshop.png deleted file mode 100644 index 24241d4f..00000000 Binary files a/lnbits/extensions/offlineshop/static/image/offlineshop.png and /dev/null differ diff --git a/lnbits/extensions/offlineshop/static/js/index.js b/lnbits/extensions/offlineshop/static/js/index.js deleted file mode 100644 index 7ade1bb9..00000000 --- a/lnbits/extensions/offlineshop/static/js/index.js +++ /dev/null @@ -1,230 +0,0 @@ -/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */ - -Vue.component(VueQrcode.name, VueQrcode) - -const pica = window.pica() - -function imgSizeFit(img, maxWidth = 1024, maxHeight = 768) { - let ratio = Math.min( - 1, - maxWidth / img.naturalWidth, - maxHeight / img.naturalHeight - ) - return {width: img.naturalWidth * ratio, height: img.naturalHeight * ratio} -} - -const defaultItemData = { - unit: 'sat' -} - -new Vue({ - el: '#vue', - mixins: [windowMixin], - data() { - return { - selectedWallet: null, - confirmationMethod: 'wordlist', - wordlistTainted: false, - offlineshop: { - method: null, - wordlist: [], - items: [] - }, - itemDialog: { - show: false, - urlImg: true, - data: {...defaultItemData}, - units: ['sat'] - } - } - }, - computed: { - printItems() { - return this.offlineshop.items.filter(({enabled}) => enabled) - } - }, - methods: { - openNewDialog() { - this.itemDialog.show = true - this.itemDialog.data = {...defaultItemData} - }, - openUpdateDialog(itemId) { - this.itemDialog.show = true - let item = this.offlineshop.items.find(item => item.id === itemId) - if (item.image.startsWith('data:')) { - this.itemDialog.urlImg = false - } - this.itemDialog.data = item - }, - imageAdded(file) { - let blobURL = URL.createObjectURL(file) - let image = new Image() - image.src = blobURL - image.onload = async () => { - let fit = imgSizeFit(image, 100, 100) - let canvas = document.createElement('canvas') - canvas.setAttribute('width', fit.width) - canvas.setAttribute('height', fit.height) - output = await pica.resize(image, canvas) - this.itemDialog.data.image = output.toDataURL('image/jpeg', 0.4) - this.itemDialog = {...this.itemDialog} - } - }, - imageCleared() { - this.itemDialog.data.image = null - this.itemDialog = {...this.itemDialog} - }, - 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 - this.confirmationMethod = response.data.method - this.wordlistTainted = false - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - }, - async setMethod() { - try { - await LNbits.api.request( - 'PUT', - '/offlineshop/api/v1/offlineshop/method', - this.selectedWallet.inkey, - {method: this.confirmationMethod, wordlist: this.offlineshop.wordlist} - ) - } catch (err) { - LNbits.utils.notifyApiError(err) - return - } - - this.$q.notify({ - message: - `Method set to ${this.confirmationMethod}.` + - (this.confirmationMethod === 'wordlist' ? ' Counter reset.' : ''), - timeout: 700 - }) - this.loadShop() - }, - async sendItem() { - let {id, name, image, description, price, unit} = this.itemDialog.data - const data = { - name, - description, - image, - price, - unit, - fiat_base_multiplier: unit == 'sat' ? 1 : 100 - } - - try { - if (id) { - await LNbits.api.request( - 'PUT', - '/offlineshop/api/v1/offlineshop/items/' + id, - this.selectedWallet.inkey, - data - ) - } else { - await LNbits.api.request( - 'POST', - '/offlineshop/api/v1/offlineshop/items', - this.selectedWallet.inkey, - data - ) - this.$q.notify({ - message: `Item '${this.itemDialog.data.name}' added.`, - timeout: 700 - }) - } - } catch (err) { - LNbits.utils.notifyApiError(err) - return - } - - this.loadShop() - this.itemDialog.show = false - this.itemDialog.urlImg = true - this.itemDialog.data = {...defaultItemData} - }, - 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() - - LNbits.api - .request('GET', '/offlineshop/api/v1/currencies') - .then(response => { - this.itemDialog = {...this.itemDialog, units: ['sat', ...response.data]} - }) - .catch(err => { - LNbits.utils.notifyApiError(err) - }) - } -}) diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html b/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html deleted file mode 100644 index 0a4b9df8..00000000 --- a/lnbits/extensions/offlineshop/templates/offlineshop/_api_docs.html +++ /dev/null @@ -1,154 +0,0 @@ - - - -
    -
  1. Register items.
  2. -
  3. - Print QR codes and paste them on your store, your menu, somewhere, - somehow. -
  4. -
  5. - Clients scan the QR codes and get information about the items plus the - price on their phones directly (they must have internet) -
  6. -
  7. - Once they decide to pay, they'll get an invoice on their phones - automatically -
  8. -
  9. - When the payment is confirmed, a confirmation code will be issued for - them. -
  10. -
-

- The confirmation codes are words from a predefined sequential word list. - Each new payment bumps the words sequence by 1. So you can check the - confirmation codes manually by just looking at them. -

-

- For example, if your wordlist is - [apple, banana, coconut] the first purchase will be - apple, the second banana and so on. When it - gets to the end it starts from the beginning again. -

-

Powered by LNURL-pay.

-
-
-
- - - - - - - POST -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
Returns 201 OK
-
Curl example
- curl -X GET {{ request.base_url - }}offlineshop/api/v1/offlineshop/items -H "Content-Type: - 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">}' - -
-
-
- - - - GET -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
- Returns 200 OK (application/json) -
- {"id": <integer>, "wallet": <string>, "wordlist": - <string>, "items": [{"id": <integer>, "name": - <string>, "description": <string>, "image": - <string>, "enabled": <boolean>, "price": <integer>, - "unit": <string>, "lnurl": <string>}, ...]}< -
Curl example
- curl -X GET {{ request.base_url }}offlineshop/api/v1/offlineshop -H - "X-Api-Key: {{ user.wallets[0].inkey }}" - -
-
-
- - - - PUT -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
Returns 200 OK
-
Curl example
- curl -X GET {{ request.base_url - }}offlineshop/api/v1/offlineshop/items/<item_id> -H - "Content-Type: 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">}' - -
-
-
- - - - DELETE -
Headers
- {"X-Api-Key": <invoice_key>}
-
Body (application/json)
-
Returns 200 OK
-
Curl example
- curl -X GET {{ request.base_url - }}offlineshop/api/v1/offlineshop/items/<item_id> -H "X-Api-Key: - {{ user.wallets[0].inkey }}" - -
-
-
-
diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/index.html b/lnbits/extensions/offlineshop/templates/offlineshop/index.html deleted file mode 100644 index 80a7bbb8..00000000 --- a/lnbits/extensions/offlineshop/templates/offlineshop/index.html +++ /dev/null @@ -1,348 +0,0 @@ -{% extends "base.html" %} {% from "macros.jinja" import window_vars with context -%} {% block page %} -
-
- - -
-
-
Items
-
-
- Add new item -
-
- {% raw %} - - - - - {% endraw %} -
-
- - - -
-
Wallet Shop
-
- - - - - - -
- Print QR Codes -
-
-
- - - - - - - - - - -
-
- -
-
- - Update Wordlist - - Reset -
-
-
- -
-
-
- - - -
-
- - Set TOTP - -
-
-
- -
-

- Setting this option disables the confirmation code message that - appears in the consumer wallet after a purchase is paid for. It's ok - if the consumer is to be trusted when they claim to have paid. -

- - - Disable Confirmation Codes - -
-
-
-
- -
- - -
- {{SITE_TITLE}} OfflineShop extension -
-
- - - {% include "offlineshop/_api_docs.html" %} - -
-
- - - - -
-
Adding a new item
- - - - - -
- Copy LNURL -
- - - - - - - - - - - - -
-
- - {% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %} - Item - -
-
- Cancel -
-
-
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - - -{% endblock %} diff --git a/lnbits/extensions/offlineshop/templates/offlineshop/print.html b/lnbits/extensions/offlineshop/templates/offlineshop/print.html deleted file mode 100644 index a3bf5861..00000000 --- a/lnbits/extensions/offlineshop/templates/offlineshop/print.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "print.html" %} {% block page %} {% raw %} -
-
-
{{ item.name }}
- -
{{ item.price }}
-
-
-{% endraw %} {% endblock %} {% block scripts %} - -{% endblock %} diff --git a/lnbits/extensions/offlineshop/views.py b/lnbits/extensions/offlineshop/views.py deleted file mode 100644 index ebde1762..00000000 --- a/lnbits/extensions/offlineshop/views.py +++ /dev/null @@ -1,89 +0,0 @@ -import time -from datetime import datetime -from http import HTTPStatus - -from fastapi import Depends, HTTPException, Query, Request -from starlette.responses import HTMLResponse - -from lnbits.core.crud import get_standalone_payment -from lnbits.core.models import User -from lnbits.core.views.api import api_payment -from lnbits.decorators import check_user_exists - -from . import offlineshop_ext, offlineshop_renderer -from .crud import get_item, get_shop - - -@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.query_params.get("items").split(","): - item = await get_item(item_id) - if item: - items.append( - { - "lnurl": item.lnurl(request), - "name": item.name, - "price": f"{item.price} {item.unit}", - } - ) - - return offlineshop_renderer().TemplateResponse( - "offlineshop/print.html", {"request": request, "items": items} - ) - - -@offlineshop_ext.get( - "/confirmation/{p}", - name="offlineshop.confirmation_code", - response_class=HTMLResponse, -) -async def confirmation_code(p: str = Query(...)): - style = "" - - payment_hash = p - await api_payment(payment_hash) - - payment = await get_standalone_payment(payment_hash) - if not payment: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail=f"Couldn't find the payment {payment_hash}." + style, - ) - if payment.pending: - 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(): - raise HTTPException( - status_code=HTTPStatus.REQUEST_TIMEOUT, - detail="Too much time has passed." + style, - ) - - assert payment.extra - item_id = payment.extra.get("item") - assert item_id - item = await get_item(item_id) - assert item - shop = await get_shop(item.shop) - assert shop - - return ( - f""" -[{shop.get_code(payment_hash)}]
-{item.name}
-{item.price} {item.unit}
-{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')} - """ - + style - ) diff --git a/lnbits/extensions/offlineshop/views_api.py b/lnbits/extensions/offlineshop/views_api.py deleted file mode 100644 index 22dca69b..00000000 --- a/lnbits/extensions/offlineshop/views_api.py +++ /dev/null @@ -1,136 +0,0 @@ -from http import HTTPStatus -from typing import Optional - -from fastapi import Depends, HTTPException, Query, Request, Response -from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl -from pydantic import BaseModel - -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, - update_item, -) -from .models import ShopCounter - - -@offlineshop_ext.get("/api/v1/currencies") -async def api_list_currencies_available(): - return list(currencies.keys()) - - -@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) - assert shop - items = await get_items(shop.id) - try: - return { - **shop.dict(), - **{"otp_key": shop.otp_key, "items": [item.values(r) for item in items]}, - } - except LnurlInvalidUrl: - 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 - description: str - image: Optional[str] - price: float - unit: str - fiat_base_multiplier: int = Query(100, ge=1) - - -@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) - assert shop - if data.image: - image_is_url = data.image.startswith("https://") or data.image.startswith( - "http://" - ) - - if not image_is_url: - - def size(b64string): - return int((len(b64string) * 3) / 4 - b64string.count("=", -2)) - - image_size = size(data.image) / 1024 - if image_size > 100: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Image size is too big, {int(image_size)}Kb. Max: 100kb, Compress the image at https://tinypng.com, or use an URL.", - ) - if data.unit != "sat": - data.price = data.price * 100 - if item_id is None: - - await add_item( - shop.id, - data.name, - data.description, - data.image, - int(data.price), - data.unit, - data.fiat_base_multiplier, - ) - return Response(status_code=HTTPStatus.CREATED) - else: - await update_item( - shop.id, - item_id, - data.name, - data.description, - data.image, - int(data.price), - data.unit, - data.fiat_base_multiplier, - ) - - -@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) - assert shop - await delete_item_from_shop(shop.id, item_id) - return "", HTTPStatus.NO_CONTENT - - -class CreateMethodData(BaseModel): - method: str - wordlist: Optional[str] - - -@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 [] - wordlist = [word.strip() for word in wordlist if word.strip()] - - shop = await get_or_create_shop_by_wallet(wallet.wallet.id) - if not shop: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND) - - updated_shop = await set_method(shop.id, method, "\n".join(wordlist)) - if not updated_shop: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND) - - ShopCounter.reset(updated_shop) diff --git a/lnbits/extensions/offlineshop/wordlists.py b/lnbits/extensions/offlineshop/wordlists.py deleted file mode 100644 index fa0e574d..00000000 --- a/lnbits/extensions/offlineshop/wordlists.py +++ /dev/null @@ -1,28 +0,0 @@ -animals = [ - "albatross", - "bison", - "chicken", - "duck", - "eagle", - "flamingo", - "gorilla", - "hamster", - "iguana", - "jaguar", - "koala", - "llama", - "macaroni penguin", - "numbat", - "octopus", - "platypus", - "quetzal", - "rabbit", - "salmon", - "tuna", - "unicorn", - "vulture", - "wolf", - "xenops", - "yak", - "zebra", -]