diff --git a/crud.py b/crud.py index 96c5bf0..996bfc3 100644 --- a/crud.py +++ b/crud.py @@ -1,10 +1,20 @@ import json +import time from typing import List, Optional from lnbits.helpers import urlsafe_short_hash from . import db -from .models import Merchant, PartialMerchant, PartialZone, Zone +from .models import ( + Merchant, + PartialMerchant, + PartialProduct, + PartialStall, + PartialZone, + Product, + Stall, + Zone, +) ######################################## MERCHANT ######################################## @@ -51,15 +61,7 @@ async def create_zone(user_id: str, data: PartialZone) -> Zone: zone_id = urlsafe_short_hash() await db.execute( f""" - INSERT INTO nostrmarket.zones ( - id, - user_id, - name, - currency, - cost, - regions - - ) + INSERT INTO nostrmarket.zones (id, user_id, name, currency, cost, regions) VALUES (?, ?, ?, ?, ?, ?) """, ( @@ -104,4 +106,166 @@ async def get_zones(user_id: str) -> List[Zone]: async def delete_zone(zone_id: str) -> None: + # todo: add user_id await db.execute("DELETE FROM nostrmarket.zones WHERE id = ?", (zone_id,)) + + +######################################## STALL ######################################## + + +async def create_stall(user_id: str, data: PartialStall) -> Stall: + stall_id = urlsafe_short_hash() + + await db.execute( + f""" + INSERT INTO nostrmarket.stalls (user_id, id, wallet, name, currency, zones, meta) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + stall_id, + data.wallet, + data.name, + data.currency, + json.dumps( + [z.dict() for z in data.shipping_zones] + ), # todo: cost is float. should be int for sats + json.dumps(data.config.dict()), + ), + ) + + stall = await get_stall(user_id, stall_id) + assert stall, "Newly created stall couldn't be retrieved" + return stall + + +async def get_stall(user_id: str, stall_id: str) -> Optional[Stall]: + row = await db.fetchone( + "SELECT * FROM nostrmarket.stalls WHERE user_id = ? AND id = ?", + ( + user_id, + stall_id, + ), + ) + return Stall.from_row(row) if row else None + + +async def get_stalls(user_id: str) -> List[Stall]: + rows = await db.fetchall( + "SELECT * FROM nostrmarket.stalls WHERE user_id = ?", + (user_id,), + ) + return [Stall.from_row(row) for row in rows] + + +async def update_stall(user_id: str, stall: Stall) -> Optional[Stall]: + await db.execute( + f""" + UPDATE nostrmarket.stalls SET wallet = ?, name = ?, currency = ?, zones = ?, meta = ? + WHERE user_id = ? AND id = ? + """, + ( + stall.wallet, + stall.name, + stall.currency, + json.dumps( + [z.dict() for z in stall.shipping_zones] + ), # todo: cost is float. should be int for sats + json.dumps(stall.config.dict()), + user_id, + stall.id, + ), + ) + return await get_stall(user_id, stall.id) + + +async def delete_stall(user_id: str, stall_id: str) -> None: + await db.execute( + "DELETE FROM nostrmarket.stalls WHERE user_id =? AND id = ?", + ( + user_id, + stall_id, + ), + ) + + +######################################## STALL ######################################## + + +async def create_product(user_id: str, data: PartialProduct) -> Product: + product_id = urlsafe_short_hash() + + await db.execute( + f""" + INSERT INTO nostrmarket.products (user_id, id, stall_id, name, category_list, description, images, price, quantity) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + product_id, + data.stall_id, + data.name, + json.dumps(data.categories), + data.description, + data.image, + data.price, + data.quantity, + ), + ) + product = await get_product(user_id, product_id) + assert product, "Newly created product couldn't be retrieved" + + return product + + +async def update_product(user_id: str, product: Product) -> Product: + + await db.execute( + f""" + UPDATE nostrmarket.products set name = ?, category_list = ?, description = ?, images = ?, price = ?, quantity = ? + WHERE user_id = ? AND id = ? + """, + ( + product.name, + json.dumps(product.categories), + product.description, + product.image, + product.price, + product.quantity, + user_id, + product.id, + ), + ) + updated_product = await get_product(user_id, product.id) + assert updated_product, "Updated product couldn't be retrieved" + + return updated_product + + +async def get_product(user_id: str, product_id: str) -> Optional[Product]: + row = await db.fetchone( + "SELECT * FROM nostrmarket.products WHERE user_id =? AND id = ?", + ( + user_id, + product_id, + ), + ) + return Product.from_row(row) if row else None + + +async def get_products(user_id: str, stall_id: str) -> List[Product]: + rows = await db.fetchall( + "SELECT * FROM nostrmarket.products WHERE user_id = ? AND stall_id = ?", + (user_id, stall_id), + ) + return [Product.from_row(row) for row in rows] + + +async def delete_product(user_id: str, product_id: str) -> None: + await db.execute( + "DELETE FROM nostrmarket.products WHERE user_id =? AND id = ?", + ( + user_id, + product_id, + ), + ) diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..8747c48 --- /dev/null +++ b/helpers.py @@ -0,0 +1,81 @@ +import base64 +import json +import secrets +from typing import Optional + +import secp256k1 +from cffi import FFI +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + +def get_shared_secret(privkey: str, pubkey: str): + point = secp256k1.PublicKey(bytes.fromhex("02" + pubkey), True) + return point.ecdh(bytes.fromhex(privkey), hashfn=copy_x) + + +def decrypt_message(encoded_message: str, encryption_key) -> str: + encoded_data = encoded_message.split("?iv=") + encoded_content, encoded_iv = encoded_data[0], encoded_data[1] + + iv = base64.b64decode(encoded_iv) + cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv)) + encrypted_content = base64.b64decode(encoded_content) + + decryptor = cipher.decryptor() + decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize() + + unpadder = padding.PKCS7(128).unpadder() + unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize() + + return unpadded_data.decode() + + +def encrypt_message(message: str, encryption_key, iv: Optional[bytes]) -> str: + padder = padding.PKCS7(128).padder() + padded_data = padder.update(message.encode()) + padder.finalize() + + iv = iv if iv else secrets.token_bytes(16) + cipher = Cipher(algorithms.AES(encryption_key), modes.CBC(iv)) + + encryptor = cipher.encryptor() + encrypted_message = encryptor.update(padded_data) + encryptor.finalize() + + return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}" + + +def sign_message_hash(private_key: str, hash: bytes) -> str: + privkey = secp256k1.PrivateKey(bytes.fromhex(private_key)) + sig = privkey.schnorr_sign(hash, None, raw=True) + return sig.hex() + + +def test_decrypt_encrypt(encoded_message: str, encryption_key): + msg = decrypt_message(encoded_message, encryption_key) + + # ecrypt using the same initialisation vector + iv = base64.b64decode(encoded_message.split("?iv=")[1]) + ecrypted_msg = encrypt_message(msg, encryption_key, iv) + assert ( + encoded_message == ecrypted_msg + ), f"expected '{encoded_message}', but got '{ecrypted_msg}'" + print("### test_decrypt_encrypt", encoded_message == ecrypted_msg) + + +ffi = FFI() + + +@ffi.callback( + "int (unsigned char *, const unsigned char *, const unsigned char *, void *)" +) +def copy_x(output, x32, y32, data): + ffi.memmove(output, x32, 32) + return 1 + + +def is_json(string: str): + try: + json.loads(string) + except ValueError as e: + return False + return True diff --git a/migrations.py b/migrations.py index 04a4fe1..2e0a158 100644 --- a/migrations.py +++ b/migrations.py @@ -18,15 +18,17 @@ async def m001_initial(db): """ Initial stalls table. """ + # user_id, id, wallet, name, currency, zones, meta await db.execute( """ CREATE TABLE nostrmarket.stalls ( + user_id TEXT NOT NULL, id TEXT PRIMARY KEY, wallet TEXT NOT NULL, name TEXT NOT NULL, currency TEXT, - shipping_zones TEXT NOT NULL, - rating REAL DEFAULT 0 + zones TEXT NOT NULL DEFAULT '[]', + meta TEXT NOT NULL DEFAULT '{}' ); """ ) @@ -37,15 +39,15 @@ async def m001_initial(db): await db.execute( f""" CREATE TABLE nostrmarket.products ( + user_id TEXT NOT NULL, id TEXT PRIMARY KEY, stall_id TEXT NOT NULL, name TEXT NOT NULL, - categories TEXT, + category_list TEXT DEFAULT '[]', description TEXT, - image TEXT, + images TEXT DEFAULT '[]', price REAL NOT NULL, - quantity INTEGER NOT NULL, - rating REAL DEFAULT 0 + quantity INTEGER NOT NULL ); """ ) diff --git a/models.py b/models.py index 40bb57f..412bbfc 100644 --- a/models.py +++ b/models.py @@ -1,10 +1,13 @@ import json +import time from sqlite3 import Row from typing import List, Optional -from fastapi import Query from pydantic import BaseModel +from .helpers import sign_message_hash +from .nostr.event import NostrEvent + ######################################## MERCHANT ######################################## class MerchantConfig(BaseModel): @@ -20,6 +23,9 @@ class PartialMerchant(BaseModel): class Merchant(PartialMerchant): id: str + def sign_hash(self, hash: bytes) -> str: + return sign_message_hash(self.private_key, hash) + @classmethod def from_row(cls, row: Row) -> "Merchant": merchant = cls(**dict(row)) @@ -43,3 +49,134 @@ class Zone(PartialZone): zone = cls(**dict(row)) zone.countries = json.loads(row["regions"]) return zone + + +######################################## STALLS ######################################## + + +class StallConfig(BaseModel): + """Last published nostr event id for this Stall""" + + event_id: Optional[str] + image_url: Optional[str] + description: Optional[str] + + +class PartialStall(BaseModel): + wallet: str + name: str + currency: str = "sat" + shipping_zones: List[Zone] = [] + config: StallConfig = StallConfig() + + def validate_stall(self): + for z in self.shipping_zones: + if z.currency != self.currency: + raise ValueError( + f"Sipping zone '{z.name}' has different currency than stall." + ) + + +class Stall(PartialStall): + id: str + + def to_nostr_event(self, pubkey: str) -> NostrEvent: + content = { + "name": self.name, + "description": self.config.description, + "currency": self.currency, + "shipping": [dict(z) for z in self.shipping_zones], + } + event = NostrEvent( + pubkey=pubkey, + created_at=round(time.time()), + kind=30005, + tags=[["d", self.id]], + content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), + ) + event.id = event.event_id + + return event + + def to_nostr_delete_event(self, pubkey: str) -> NostrEvent: + delete_event = NostrEvent( + pubkey=pubkey, + created_at=round(time.time()), + kind=5, + tags=[["e", self.config.event_id]], + content=f"Stall '{self.name}' deleted", + ) + delete_event.id = delete_event.event_id + + return delete_event + + @classmethod + def from_row(cls, row: Row) -> "Stall": + stall = cls(**dict(row)) + stall.config = StallConfig(**json.loads(row["meta"])) + stall.shipping_zones = [Zone(**z) for z in json.loads(row["zones"])] + return stall + + +######################################## STALLS ######################################## + + +class PartialProduct(BaseModel): + stall_id: str + name: str + categories: List[str] = [] + description: Optional[str] + image: Optional[str] + price: float + quantity: int + + def validate_product(self): + if self.image: + image_is_url = self.image.startswith("https://") or self.image.startswith( + "http://" + ) + + if not image_is_url: + + def size(b64string): + return int((len(b64string) * 3) / 4 - b64string.count("=", -2)) + + image_size = size(self.image) / 1024 + if image_size > 100: + raise ValueError( + f""" + Image size is too big, {int(image_size)}Kb. + Max: 100kb, Compress the image at https://tinypng.com, or use an URL.""" + ) + + +class Product(PartialProduct): + id: str + + def to_nostr_event(self, pubkey: str) -> NostrEvent: + content = { + "stall_id": self.stall_id, + "name": self.name, + "description": self.description, + "image": self.image, + "price": self.price, + "quantity": self.quantity, + } + categories = [["t", tag] for tag in self.categories] + + event = NostrEvent( + pubkey=pubkey, + created_at=round(time.time()), + kind=30005, + tags=[["d", self.id]] + categories, + content=json.dumps(content, separators=(",", ":"), ensure_ascii=False), + ) + event.id = event.event_id + + return event + + @classmethod + def from_row(cls, row: Row) -> "Product": + product = cls(**dict(row)) + product.categories = json.loads(row["category_list"]) + return product diff --git a/nostr/event.py b/nostr/event.py new file mode 100644 index 0000000..c92a4fa --- /dev/null +++ b/nostr/event.py @@ -0,0 +1,57 @@ +import hashlib +import json +from typing import List, Optional + +from pydantic import BaseModel +from secp256k1 import PublicKey + + +class NostrEvent(BaseModel): + id: str = "" + pubkey: str + created_at: int + kind: int + tags: List[List[str]] = [] + content: str = "" + sig: Optional[str] + + def serialize(self) -> List: + return [0, self.pubkey, self.created_at, self.kind, self.tags, self.content] + + def serialize_json(self) -> str: + e = self.serialize() + return json.dumps(e, separators=(",", ":"), ensure_ascii=False) + + @property + def event_id(self) -> str: + data = self.serialize_json() + id = hashlib.sha256(data.encode()).hexdigest() + return id + + def check_signature(self): + event_id = self.event_id + if self.id != event_id: + raise ValueError( + f"Invalid event id. Expected: '{event_id}' got '{self.id}'" + ) + try: + pub_key = PublicKey(bytes.fromhex("02" + self.pubkey), True) + except Exception: + raise ValueError( + f"Invalid public key: '{self.pubkey}' for event '{self.id}'" + ) + + valid_signature = pub_key.schnorr_verify( + bytes.fromhex(event_id), bytes.fromhex(self.sig), None, raw=True + ) + if not valid_signature: + raise ValueError(f"Invalid signature: '{self.sig}' for event '{self.id}'") + + def stringify(self) -> str: + return json.dumps(dict(self)) + + def tag_values(self, tag_name: str) -> List[str]: + return [t[1] for t in self.tags if t[0] == tag_name] + + def has_tag_value(self, tag_name: str, tag_value: str) -> bool: + return tag_value in self.tag_values(tag_name) diff --git a/nostr/nostr_client.py b/nostr/nostr_client.py new file mode 100644 index 0000000..29a5b42 --- /dev/null +++ b/nostr/nostr_client.py @@ -0,0 +1,21 @@ +import httpx +from loguru import logger + +from lnbits.app import settings +from lnbits.helpers import url_for + +from .event import NostrEvent + + +async def publish_nostr_event(e: NostrEvent): + url = url_for("/nostrclient/api/v1/publish", external=True) + data = dict(e) + print("### published", dict(data)) + async with httpx.AsyncClient() as client: + try: + await client.post( + url, + json=data, + ) + except Exception as ex: + logger.warning(ex) diff --git a/static/components/shipping-zones/shipping-zones.html b/static/components/shipping-zones/shipping-zones.html index 100afe9..cedaca7 100644 --- a/static/components/shipping-zones/shipping-zones.html +++ b/static/components/shipping-zones/shipping-zones.html @@ -4,7 +4,6 @@ unelevated color="primary" icon="public" - label="Shipping Zones" @click="openZoneDialog()" > @@ -48,8 +47,8 @@ v-model="zoneDialog.data.countries" > + + + + + + + +
+
+
Name:
+
+ +
+
+
+
+
Description:
+
+ +
+
+
+
+
Wallet:
+
+ + +
+
+
+
+
Currency:
+
+ +
+
+
+
+
Shipping Zones:
+
+ +
+
+
+
+
+
+ Update Stall +
+
+ Delete Stall +
+
+
+ +
+
+
+ New Product +
+
+
+
+ +
+
+ + + +
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + +
+ Update Product + + Create Product + + Cancel +
+
+
+
+ diff --git a/static/components/stall-details/stall-details.js b/static/components/stall-details/stall-details.js index 5b929e1..e62caaf 100644 --- a/static/components/stall-details/stall-details.js +++ b/static/components/stall-details/stall-details.js @@ -1,15 +1,309 @@ async function stallDetails(path) { const template = await loadTemplateAsync(path) + + const pica = window.pica() + Vue.component('stall-details', { name: 'stall-details', template, - //props: ['stall-id', 'adminkey', 'inkey', 'wallet-options'], + props: [ + 'stall-id', + 'adminkey', + 'inkey', + 'wallet-options', + 'zone-options', + 'currencies' + ], data: function () { return { - tab: 'info', - relay: null + tab: 'products', + stall: null, + products: [], + productDialog: { + showDialog: false, + url: true, + data: { + id: null, + name: '', + description: '', + categories: [], + image: null, + price: 0, + quantity: 0 + } + }, + productsFilter: '', + productsTable: { + columns: [ + { + name: 'delete', + align: 'left', + label: '', + field: '' + }, + { + name: 'edit', + align: 'left', + label: '', + field: '' + }, + + { + name: 'id', + align: 'left', + label: 'ID', + field: 'id' + }, + { + name: 'name', + align: 'left', + label: 'Name', + field: 'name' + }, + { + name: 'price', + align: 'left', + label: 'Price', + field: 'price' + }, + { + name: 'quantity', + align: 'left', + label: 'Quantity', + field: 'quantity' + }, + { + name: 'categories', + align: 'left', + label: 'Categories', + field: 'categories' + }, + { + name: 'description', + align: 'left', + label: 'Description', + field: 'description' + } + ], + pagination: { + rowsPerPage: 10 + } + } } + }, + computed: { + filteredZoneOptions: function () { + if (!this.stall) return [] + return this.zoneOptions.filter(z => z.currency === this.stall.currency) + } + }, + methods: { + mapStall: function (stall) { + stall.shipping_zones.forEach( + z => + (z.label = z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ')) + ) + return stall + }, + getStall: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/stall/' + this.stallId, + this.inkey + ) + this.stall = this.mapStall(data) + + console.log('### this.stall', this.stall) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + updateStall: async function () { + try { + const {data} = await LNbits.api.request( + 'PUT', + '/nostrmarket/api/v1/stall/' + this.stallId, + this.adminkey, + this.stall + ) + this.stall = this.mapStall(data) + this.$emit('stall-updated', this.stall) + this.$q.notify({ + type: 'positive', + message: 'Stall Updated', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + deleteStall: function () { + LNbits.utils + .confirmDialog( + ` + Products and orders will be deleted also! + Are you sure you want to delete this stall? + ` + ) + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/stall/' + this.stallId, + this.adminkey + ) + this.$emit('stall-deleted', this.stallId) + this.$q.notify({ + type: 'positive', + message: 'Stall Deleted', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) + }, + imageAdded(file) { + const image = new Image() + image.src = URL.createObjectURL(file) + image.onload = async () => { + let fit = imgSizeFit(image) + let canvas = document.createElement('canvas') + canvas.setAttribute('width', fit.width) + canvas.setAttribute('height', fit.height) + output = await pica.resize(image, canvas) + this.productDialog.data.image = output.toDataURL('image/jpeg', 0.4) + this.productDialog = {...this.productDialog} + } + }, + imageCleared() { + this.productDialog.data.image = null + this.productDialog = {...this.productDialog} + }, + getProducts: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/product/' + this.stall.id, + this.inkey + ) + this.products = data + + console.log('### this.products', this.products) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + sendProductFormData: function () { + var data = { + stall_id: this.stall.id, + id: this.productDialog.data.id, + name: this.productDialog.data.name, + description: this.productDialog.data.description, + categories: this.productDialog.data.categories, + + image: this.productDialog.data.image, + price: this.productDialog.data.price, + quantity: this.productDialog.data.quantity + } + this.productDialog.showDialog = false + if (this.productDialog.data.id) { + this.updateProduct(data) + } else { + this.createProduct(data) + } + }, + updateProduct: async function (product) { + try { + const {data} = await LNbits.api.request( + 'PATCH', + '/nostrmarket/api/v1/product/' + product.id, + this.adminkey, + product + ) + const index = this.products.findIndex(r => r.id === product.id) + if (index !== -1) { + this.products.splice(index, 1, data) + } + this.$q.notify({ + type: 'positive', + message: 'Product Updated', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + createProduct: async function (payload) { + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/product', + this.adminkey, + payload + ) + this.products.unshift(data) + this.$q.notify({ + type: 'positive', + message: 'Product Created', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }, + editProduct: async function (product) { + this.productDialog.data = {...product} + this.productDialog.showDialog = true + }, + deleteProduct: async function (productId) { + LNbits.utils + .confirmDialog('Are you sure you want to delete this product?') + .onOk(async () => { + try { + await LNbits.api.request( + 'DELETE', + '/nostrmarket/api/v1/product/' + productId, + this.adminkey + ) + this.products = _.reject(this.products, function (obj) { + return obj.id === productId + }) + this.$q.notify({ + type: 'positive', + message: 'Product deleted', + timeout: 5000 + }) + } catch (error) { + console.warn(error) + LNbits.utils.notifyApiError(error) + } + }) + }, + showNewProductDialog: async function () { + this.productDialog.data = { + id: null, + name: '', + description: '', + categories: [], + image: null, + price: 0, + quantity: 0 + } + this.productDialog.showDialog = true + } + }, + created: async function () { + await this.getStall() + await this.getProducts() } }) } diff --git a/static/components/stall-list/stall-list.html b/static/components/stall-list/stall-list.html new file mode 100644 index 0000000..86ae34e --- /dev/null +++ b/static/components/stall-list/stall-list.html @@ -0,0 +1,149 @@ +
+
+
+ New Stall + + + +
+
+ + + + + +
+ + + + + + + + + + +
+ Create Stall + Cancel +
+
+
+
+
+
diff --git a/static/components/stall-list/stall-list.js b/static/components/stall-list/stall-list.js new file mode 100644 index 0000000..5fd8ffd --- /dev/null +++ b/static/components/stall-list/stall-list.js @@ -0,0 +1,175 @@ +async function stallList(path) { + const template = await loadTemplateAsync(path) + Vue.component('stall-list', { + name: 'stall-list', + template, + + props: [`adminkey`, 'inkey', 'wallet-options'], + data: function () { + return { + filter: '', + stalls: [], + currencies: [], + stallDialog: { + show: false, + data: { + name: '', + description: '', + wallet: null, + currency: 'sat', + shippingZones: [] + } + }, + zoneOptions: [], + stallsTable: { + columns: [ + { + name: '', + align: 'left', + label: '', + field: '' + }, + { + name: 'id', + align: 'left', + label: 'Name', + field: 'id' + }, + { + name: 'description', + align: 'left', + label: 'Description', + field: 'description' + }, + { + name: 'shippingZones', + align: 'left', + label: 'Shipping Zones', + field: 'shippingZones' + } + ], + pagination: { + rowsPerPage: 10 + } + } + } + }, + computed: { + filteredZoneOptions: function () { + return this.zoneOptions.filter( + z => z.currency === this.stallDialog.data.currency + ) + } + }, + methods: { + sendStallFormData: async function () { + await this.createStall({ + name: this.stallDialog.data.name, + wallet: this.stallDialog.data.wallet, + currency: this.stallDialog.data.currency, + shipping_zones: this.stallDialog.data.shippingZones, + config: { + description: this.stallDialog.data.description + } + }) + }, + createStall: async function (stall) { + try { + const {data} = await LNbits.api.request( + 'POST', + '/nostrmarket/api/v1/stall', + this.adminkey, + stall + ) + this.stallDialog.show = false + data.expanded = false + this.stalls.unshift(data) + this.$q.notify({ + type: 'positive', + message: 'Stall created!' + }) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getCurrencies: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/currencies', + this.inkey + ) + + this.currencies = ['sat', ...data] + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getStalls: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/stall', + this.inkey + ) + this.stalls = data.map(s => ({...s, expanded: false})) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + getZones: async function () { + try { + const {data} = await LNbits.api.request( + 'GET', + '/nostrmarket/api/v1/zone', + this.inkey + ) + this.zoneOptions = data.map(z => ({ + ...z, + label: z.name + ? `${z.name} (${z.countries.join(', ')})` + : z.countries.join(', ') + })) + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, + handleStallDeleted: function (stallId) { + this.stalls = _.reject(this.stalls, function (obj) { + return obj.id === stallId + }) + }, + handleStallUpdated: function (stall) { + const index = this.stalls.findIndex(r => r.id === stall.id) + if (index !== -1) { + stall.expanded = true + this.stalls.splice(index, 1, stall) + } + }, + openCreateStallDialog: async function () { + await this.getCurrencies() + await this.getZones() + if (!this.zoneOptions || !this.zoneOptions.length) { + this.$q.notify({ + type: 'warning', + message: 'Please create a Shipping Zone first!' + }) + return + } + this.stallDialog.data = { + name: '', + description: '', + wallet: null, + currency: 'sat', + shippingZones: [] + } + this.stallDialog.show = true + } + }, + created: async function () { + await this.getStalls() + await this.getCurrencies() + await this.getZones() + } + }) +} diff --git a/static/js/index.js b/static/js/index.js index 68ece51..5b26d4c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,9 +1,10 @@ const merchant = async () => { Vue.component(VueQrcode.name, VueQrcode) - await stallDetails('static/components/stall-details/stall-details.html') await keyPair('static/components/key-pair/key-pair.html') await shippingZones('static/components/shipping-zones/shipping-zones.html') + await stallDetails('static/components/stall-details/stall-details.html') + await stallList('static/components/stall-list/stall-list.html') const nostr = window.NostrTools @@ -14,16 +15,49 @@ const merchant = async () => { return { merchant: {}, shippingZones: [], - showKeys: false + showKeys: false, + importKeyDialog: { + show: false, + data: { + privateKey: null + } + } } }, methods: { generateKeys: async function () { - const privkey = nostr.generatePrivateKey() - const pubkey = nostr.getPublicKey(privkey) - - const payload = {private_key: privkey, public_key: pubkey, config: {}} + const privateKey = nostr.generatePrivateKey() + await this.createMerchant(privateKey) + }, + importKeys: async function () { + this.importKeyDialog.show = false + let privateKey = this.importKeyDialog.data.privateKey + if (!privateKey) { + return + } try { + if (privateKey.toLowerCase().startsWith('nsec')) { + privateKey = nostr.nip19.decode(privateKey).data + } + } catch (error) { + this.$q.notify({ + type: 'negative', + message: `${error}` + }) + } + await this.createMerchant(privateKey) + }, + showImportKeysDialog: async function () { + this.importKeyDialog.show = true + }, + createMerchant: async function (privateKey) { + try { + const pubkey = nostr.getPublicKey(privateKey) + const payload = { + private_key: privateKey, + public_key: pubkey, + config: {} + } const {data} = await LNbits.api.request( 'POST', '/nostrmarket/api/v1/merchant', @@ -33,10 +67,13 @@ const merchant = async () => { this.merchant = data this.$q.notify({ type: 'positive', - message: 'Keys generated!' + message: 'Merchant Created!' }) } catch (error) { - LNbits.utils.notifyApiError(error) + this.$q.notify({ + type: 'negative', + message: `${error}` + }) } }, getMerchant: async function () { diff --git a/static/js/utils.js b/static/js/utils.js index 11ebc81..83e886b 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -16,3 +16,12 @@ function loadTemplateAsync(path) { return result } + +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} +} diff --git a/templates/nostrmarket/index.html b/templates/nostrmarket/index.html index b4378d8..fa07f17 100644 --- a/templates/nostrmarket/index.html +++ b/templates/nostrmarket/index.html @@ -38,7 +38,7 @@
-
-
+
+
- Show Public or Private keys + Show Public and Private keys
@@ -86,6 +88,15 @@ > + + + + +
@@ -102,14 +113,43 @@
+
+ + + + +
+ Import + Cancel +
+
+
+
+
{% endblock%}{% block scripts %} {{ window_vars(user) }} + + - + + {% endblock %} diff --git a/views_api.py b/views_api.py index f294de0..3de3f97 100644 --- a/views_api.py +++ b/views_api.py @@ -1,3 +1,4 @@ +import json from http import HTTPStatus from typing import List, Optional @@ -16,14 +17,33 @@ from lnbits.utils.exchange_rates import currencies from . import nostrmarket_ext from .crud import ( create_merchant, + create_product, + create_stall, create_zone, + delete_product, + delete_stall, delete_zone, get_merchant_for_user, + get_products, + get_stall, + get_stalls, get_zone, get_zones, + update_product, + update_stall, update_zone, ) -from .models import Merchant, PartialMerchant, PartialZone, Zone +from .models import ( + Merchant, + PartialMerchant, + PartialProduct, + PartialStall, + PartialZone, + Product, + Stall, + Zone, +) +from .nostr.nostr_client import publish_nostr_event ######################################## MERCHANT ######################################## @@ -138,6 +158,230 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi ) +######################################## STALLS ######################################## + + +@nostrmarket_ext.post("/api/v1/stall") +async def api_create_stall( + data: PartialStall, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Stall: + try: + data.validate_stall() + + print("### stall", json.dumps(data.dict())) + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, "Cannot find merchat for stall" + + stall = await create_stall(wallet.wallet.user, data=data) + + event = stall.to_nostr_event(merchant.public_key) + event.sig = merchant.sign_hash(bytes.fromhex(event.id)) + await publish_nostr_event(event) + + stall.config.event_id = event.id + await update_stall(wallet.wallet.user, stall) + + return stall + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create stall", + ) + + +@nostrmarket_ext.put("/api/v1/stall/{stall_id}") +async def api_update_stall( + data: Stall, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Stall: + try: + data.validate_stall() + + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, "Cannot find merchat for stall" + + event = data.to_nostr_event(merchant.public_key) + event.sig = merchant.sign_hash(bytes.fromhex(event.id)) + + data.config.event_id = event.id + # data.config.event_created_at = + stall = await update_stall(wallet.wallet.user, data) + assert stall, "Cannot update stall" + + await publish_nostr_event(event) + + return stall + except HTTPException as ex: + raise ex + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot update stall", + ) + + +@nostrmarket_ext.get("/api/v1/stall/{stall_id}") +async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_type)): + try: + stall = await get_stall(wallet.wallet.user, stall_id) + if not stall: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Stall does not exist.", + ) + return stall + except HTTPException as ex: + raise ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot get stall", + ) + + +@nostrmarket_ext.get("/api/v1/stall") +async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): + try: + stalls = await get_stalls(wallet.wallet.user) + return stalls + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot get stalls", + ) + + +@nostrmarket_ext.delete("/api/v1/stall/{stall_id}") +async def api_delete_stall( + stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) +): + try: + stall = await get_stall(wallet.wallet.user, stall_id) + if not stall: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail="Stall does not exist.", + ) + + merchant = await get_merchant_for_user(wallet.wallet.user) + assert merchant, "Cannot find merchat for stall" + + await delete_stall(wallet.wallet.user, stall_id) + + delete_event = stall.to_nostr_delete_event(merchant.public_key) + delete_event.sig = merchant.sign_hash(bytes.fromhex(delete_event.id)) + + await publish_nostr_event(delete_event) + + except HTTPException as ex: + raise ex + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot delete stall", + ) + + +######################################## PRODUCTS ######################################## + + +@nostrmarket_ext.post("/api/v1/product") +async def api_create_product( + data: PartialProduct, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Product: + try: + data.validate_product() + product = await create_product(wallet.wallet.user, data=data) + + return product + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot create product", + ) + + +@nostrmarket_ext.patch("/api/v1/product/{product_id}") +async def api_update_product( + product_id: str, + product: Product, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Product: + try: + product.validate_product() + product = await update_product(wallet.wallet.user, product) + + return product + except ValueError as ex: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=str(ex), + ) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot update product", + ) + + +@nostrmarket_ext.get("/api/v1/product/{stall_id}") +async def api_get_product( + stall_id: str, + wallet: WalletTypeInfo = Depends(require_invoice_key), +): + try: + products = await get_products(wallet.wallet.user, stall_id) + return products + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot get product", + ) + + +@nostrmarket_ext.delete("/api/v1/product/{product_id}") +async def api_delete_product( + product_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + try: + await delete_product(wallet.wallet.user, product_id) + except Exception as ex: + logger.warning(ex) + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Cannot delete product", + ) + + +######################################## OTHER ######################################## + + @nostrmarket_ext.get("/api/v1/currencies") async def api_list_currencies_available(): return list(currencies.keys())