Merge pull request #1502 from lnbits/remove-offlineshop

remove offlineshop
This commit is contained in:
Arc 2023-02-15 10:42:03 +00:00 committed by GitHub
commit 38d72408fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 0 additions and 1482 deletions

View File

@ -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"

View File

@ -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

View File

@ -1,8 +0,0 @@
{
"name": "OfflineShop",
"short_description": "Receive payments for products offline!",
"tile": "/offlineshop/static/image/offlineshop.png",
"contributors": [
"fiatjaf"
]
}

View File

@ -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),
)

View File

@ -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)

View File

@ -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()

View File

@ -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;"
)

View File

@ -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."
),
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@ -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)
})
}
})

View File

@ -1,154 +0,0 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="How to use"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<ol>
<li>Register items.</li>
<li>
Print QR codes and paste them on your store, your menu, somewhere,
somehow.
</li>
<li>
Clients scan the QR codes and get information about the items plus the
price on their phones directly (they must have internet)
</li>
<li>
Once they decide to pay, they'll get an invoice on their phones
automatically
</li>
<li>
When the payment is confirmed, a confirmation code will be issued for
them.
</li>
</ol>
<p>
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.
</p>
<p>
For example, if your wordlist is
<code>[apple, banana, coconut]</code> the first purchase will be
<code>apple</code>, the second <code>banana</code> and so on. When it
gets to the end it starts from the beginning again.
</p>
<p>Powered by LNURL-pay.</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/offlineshop"></q-btn>
<q-expansion-item
group="api"
dense
expand-separator
label="Create item (a shop will be created automatically based on the wallet you use)"
>
<q-card>
<q-card-section>
<code><span class="text-blue">POST</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 201 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>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": &lt;string&gt;, "description": &lt;string&gt;, "image":
&lt;data-uri string&gt;, "price": &lt;integer&gt;, "unit": &lt;"sat"
or "USD"&gt;}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Get the shop data along with items"
>
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code
>{"id": &lt;integer&gt;, "wallet": &lt;string&gt;, "wordlist":
&lt;string&gt;, "items": [{"id": &lt;integer&gt;, "name":
&lt;string&gt;, "description": &lt;string&gt;, "image":
&lt;string&gt;, "enabled": &lt;boolean&gt;, "price": &lt;integer&gt;,
"unit": &lt;string&gt;, "lnurl": &lt;string&gt;}, ...]}&lt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}offlineshop/api/v1/offlineshop -H
"X-Api-Key: {{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Update item (all fields must be sent again)"
>
<q-card>
<q-card-section>
<code><span class="text-blue">PUT</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}offlineshop/api/v1/offlineshop/items/&lt;item_id&gt; -H
"Content-Type: application/json" -H "X-Api-Key: {{
user.wallets[0].inkey }}" -d '{"name": &lt;string&gt;, "description":
&lt;string&gt;, "image": &lt;data-uri string&gt;, "price":
&lt;integer&gt;, "unit": &lt;"sat" or "USD"&gt;}'
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete item"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code><span class="text-blue">DELETE</span></code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200 OK</h5>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}offlineshop/api/v1/offlineshop/items/&lt;item_id&gt; -H "X-Api-Key:
{{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -1,348 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Items</h5>
</div>
<div class="col q-ml-lg">
<q-btn unelevated color="primary" @click="openNewDialog()"
>Add new item</q-btn
>
</div>
</div>
{% raw %}
<q-table
dense
flat
selection="multiple"
:data="offlineshop.items"
row-key="id"
no-data-label="No items for sale yet"
:pagination="{rowsPerPage: 0}"
:binary-state-sort="true"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width>Name</q-th>
<q-th auto-width>Description</q-th>
<q-th auto-width>Image</q-th>
<q-th auto-width>Price</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
:icon="props.row.enabled ? 'done' : 'block'"
:color="props.row.enabled ? 'green' : ($q.dark.isActive ? 'grey-7' : 'grey-5')"
type="a"
@click="toggleItem(props.row.id)"
target="_blank"
></q-btn>
</q-td>
<q-td auto-width class="text-center">{{ props.row.name }}</q-td>
<q-td auto-width> {{ props.row.description }} </q-td>
<q-td class="text-center" auto-width>
<img
v-if="props.row.image"
:src="props.row.image"
style="height: 1.5em"
/>
</q-td>
<q-td class="text-center" auto-width>
{{ props.row.price }} {{ props.row.unit }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="delete"
color="negative"
type="a"
@click="deleteItem(props.row.id)"
target="_blank"
></q-btn>
</q-td>
</q-tr>
</template>
</q-table>
{% endraw %}
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-card-section class="q-pa-none text-center">
<div class="row">
<h5 class="text-subtitle1 q-my-none">Wallet Shop</h5>
</div>
<q-form class="q-gutter-md">
<q-select
filled
dense
:options="g.user.wallets"
:value="selectedWallet"
label="Using wallet:"
option-label="name"
@input="changedWallet"
>
</q-select>
</q-form>
<div v-if="printItems.length > 0" class="row q-gutter-sm q-my-md">
<q-btn
type="a"
outline
color="secondary"
:href="'print?items=' + printItems.map(({id}) => id).join(',')"
>Print QR Codes</q-btn
>
</div>
</q-card-section>
</q-card>
<q-card class="q-pa-sm col-5">
<q-tabs
v-model="confirmationMethod"
no-caps
class="bg-dark text-white shadow-2"
>
<q-tab name="wordlist" label="Wordlist"></q-tab>
<q-tab name="totp" label="TOTP (Google Authenticator)"></q-tab>
<q-tab name="none" label="Nothing"></q-tab>
</q-tabs>
<q-card-section class="q-py-sm text-center">
<q-form
v-if="confirmationMethod === 'wordlist'"
class="q-gutter-md q-y-md"
@submit="setMethod"
>
<div class="row">
<div class="col q-mx-lg">
<q-input
v-model="offlineshop.wordlist"
@input="wordlistTainted = true"
dense
filled
autogrow
/>
</div>
<div
class="col q-mx-lg items-align flex items-center justify-center"
>
<q-btn
unelevated
color="primary"
type="submit"
:disabled="!wordlistTainted"
>
Update Wordlist
</q-btn>
<q-btn @click="loadShop" flat color="grey" class="q-ml-auto"
>Reset</q-btn
>
</div>
</div>
</q-form>
<div v-else-if="confirmationMethod === 'totp'">
<div class="row">
<div class="col q-mx-lg">
<q-responsive :ratio="1">
<qrcode
:value="`otpauth://totp/offlineshop:${selectedWallet.name}?secret=${offlineshop.otp_key}`"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</div>
<div
class="col q-mx-lg items-align flex items-center justify-center"
>
<q-btn
unelevated
color="primary"
:disabled="offlineshop.method === 'totp'"
@click="setMethod"
>
Set TOTP
</q-btn>
</div>
</div>
</div>
<div v-else-if="confirmationMethod === 'none'">
<p>
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.
</p>
<q-btn
unelevated
color="primary"
:disabled="offlineshop.method === 'none'"
@click="setMethod"
>
Disable Confirmation Codes
</q-btn>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} OfflineShop extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "offlineshop/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="itemDialog.show">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-card-section>
<h5
class="q-ma-none"
v-if="itemDialog.data.id"
v-text="itemDialog.data.name"
></h5>
<h5 class="q-ma-none q-mb-xl" v-else>Adding a new item</h5>
<q-responsive v-if="itemDialog.data.id" :ratio="1">
<qrcode
:value="'lightning:' + itemDialog.data.lnurl"
:options="{width: 300}"
class="rounded-borders"
></qrcode>
</q-responsive>
<div v-if="itemDialog.data.id" class="row q-gutter-sm justify-center">
<q-btn
outline
color="grey"
@click="copyText(itemDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-mb-lg"
>Copy LNURL</q-btn
>
</div>
<q-form @submit="sendItem" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="itemDialog.data.name"
type="text"
label="Item name"
></q-input>
<q-input
filled
dense
v-model.trim="itemDialog.data.description"
type="text"
label="Brief description"
></q-input>
<q-input
v-if="itemDialog.urlImg"
filled
dense
v-model.trim="itemDialog.data.image"
type="url"
label="Image URL"
></q-input>
<q-file
v-else
filled
dense
capture="environment"
accept="image/jpeg, image/png"
:max-file-size="3*1024**2"
label="Small image (optional)"
clearable
@input="imageAdded"
@clear="imageCleared"
>
<template v-if="itemDialog.data.image" v-slot:before>
<img style="height: 1em" :src="itemDialog.data.image" />
</template>
<template v-if="itemDialog.data.image" v-slot:append>
<q-icon
name="cancel"
@click.stop.prevent="imageCleared"
class="cursor-pointer"
/>
</template>
</q-file>
<q-toggle
:label="`${itemDialog.urlImg ? 'Insert image URL' : 'Upload image file'}`"
v-model="itemDialog.urlImg"
></q-toggle>
<q-input
filled
dense
v-model.number="itemDialog.data.price"
type="number"
step="0.001"
min="0.001"
:label="`Item price (${itemDialog.data.unit})`"
></q-input>
<q-select
filled
dense
v-model="itemDialog.data.unit"
type="text"
label="Unit"
:options="itemDialog.units"
></q-select>
<div class="row q-mt-lg">
<div class="col q-ml-lg">
<q-btn
unelevated
color="primary"
:disable="disabledAddItemButton()"
type="submit"
>
{% raw %}{{ itemDialog.data.id ? 'Update' : 'Add' }}{% endraw %}
Item
</q-btn>
</div>
<div class="col q-ml-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</div>
</q-form>
</q-card-section>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="https://cdn.jsdelivr.net/npm/pica@6.1.1/dist/pica.min.js"></script>
<script src="{{ url_for('offlineshop_static', path='js/index.js') }}"></script>
{% endblock %}

View File

@ -1,28 +0,0 @@
{% extends "print.html" %} {% block page %} {% raw %}
<div class="row justify-center">
<div v-for="item in items" class="q-my-sm q-mx-lg">
<div class="text-center q-ma-none q-mb-sm">{{ item.name }}</div>
<qrcode
:value="'lightning:' + item.lnurl"
:options="{margin: 0, width: 250}"
></qrcode>
<div class="text-center q-ma-none q-mt-sm">{{ item.price }}</div>
</div>
</div>
{% endraw %} {% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
created: function () {
window.print()
},
data: function () {
return {
items: JSON.parse('{{items | tojson}}')
}
}
})
</script>
{% endblock %}

View File

@ -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 = "<style>* { font-size: 100px}</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)}]<br>
{item.name}<br>
{item.price} {item.unit}<br>
{datetime.utcfromtimestamp(payment.time).strftime('%Y-%m-%d %H:%M:%S')}
"""
+ style
)

View File

@ -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)

View File

@ -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",
]