From 5972c44ad1f1bc6778e34db7b6004eec36e6d0aa Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Thu, 2 Mar 2023 10:14:11 +0200 Subject: [PATCH] feat: publish event on `C_UD` for stalls --- crud.py | 47 ++++++++----- helpers.py | 81 +++++++++++++++++++++ models.py | 47 +++++++++++-- nostr/event.py | 57 +++++++++++++++ nostr/nostr_client.py | 21 ++++++ static/components/stall-list/stall-list.js | 10 ++- views_api.py | 82 ++++++++++++++++++---- 7 files changed, 305 insertions(+), 40 deletions(-) create mode 100644 helpers.py create mode 100644 nostr/event.py create mode 100644 nostr/nostr_client.py diff --git a/crud.py b/crud.py index e66f639..55cf4ae 100644 --- a/crud.py +++ b/crud.py @@ -1,4 +1,5 @@ import json +import time from typing import List, Optional from lnbits.helpers import urlsafe_short_hash @@ -51,15 +52,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 (?, ?, ?, ?, ?, ?) """, ( @@ -112,6 +105,7 @@ async def delete_zone(zone_id: str) -> None: 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) @@ -123,8 +117,10 @@ async def create_stall(user_id: str, data: PartialStall) -> Stall: data.wallet, data.name, data.currency, - json.dumps(data.shipping_zones), - json.dumps(dict(data.config)), + json.dumps( + [z.dict() for z in data.shipping_zones] + ), # todo: cost is float. should be int for sats + json.dumps(data.config.dict()), ), ) @@ -152,17 +148,32 @@ async def get_stalls(user_id: str) -> List[Stall]: return [Stall.from_row(row) for row in rows] -async def update_stall(user_id: str, stall_id: str, **kwargs) -> Optional[Stall]: - q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()]) +async def update_stall(user_id: str, stall: Stall) -> Optional[Stall]: await db.execute( - f"UPDATE market.stalls SET {q} WHERE user_id = ? AND id = ?", - (*kwargs.values(), user_id, stall_id), + 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, + ), ) - row = await db.fetchone( - "SELECT * FROM market.stalls WHERE user_id =? AND 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, ), ) - return Stall.from_row(row) if row else None 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/models.py b/models.py index 8f98f24..bd44c44 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)) @@ -49,24 +55,57 @@ class Zone(PartialZone): class StallConfig(BaseModel): + """Last published nostr event id for this Stall""" + + event_id: Optional[str] image_url: Optional[str] - fiat_base_multiplier: int = 1 # todo: reminder wht is this for? + description: Optional[str] class PartialStall(BaseModel): wallet: str name: str currency: str = "sat" - shipping_zones: List[str] = [] + shipping_zones: List[PartialZone] = [] config: StallConfig = StallConfig() 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="Stall 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 = json.loads(row["zones"]) + stall.shipping_zones = [PartialZone(**z) for z in json.loads(row["zones"])] return stall 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/stall-list/stall-list.js b/static/components/stall-list/stall-list.js index 7ecf91f..6ba3ef5 100644 --- a/static/components/stall-list/stall-list.js +++ b/static/components/stall-list/stall-list.js @@ -30,7 +30,7 @@ async function stallList(path) { name: this.stallDialog.data.name, wallet: this.stallDialog.data.wallet, currency: this.stallDialog.data.currency, - shipping_zones: this.stallDialog.data.shippingZones.map(z => z.id), + shipping_zones: this.stallDialog.data.shippingZones, config: {} }) }, @@ -71,10 +71,14 @@ async function stallList(path) { '/nostrmarket/api/v1/zone', this.inkey ) + console.log('### zones', data) this.zoneOptions = data.map(z => ({ - id: z.id, - label: `${z.name} (${z.countries.join(', ')})` + ...z, + label: z.name + ? `${z.name} (${z.countries.join(', ')}})` + : z.countries.join(', ') })) + console.log('### this.zoneOptions', this.zoneOptions) } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/views_api.py b/views_api.py index 2488b7c..c03f7f4 100644 --- a/views_api.py +++ b/views_api.py @@ -1,3 +1,4 @@ +import json from http import HTTPStatus from typing import List, Optional @@ -18,6 +19,7 @@ from .crud import ( create_merchant, create_stall, create_zone, + delete_stall, delete_zone, get_merchant_for_user, get_stall, @@ -28,6 +30,7 @@ from .crud import ( update_zone, ) from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone +from .nostr.nostr_client import publish_nostr_event ######################################## MERCHANT ######################################## @@ -148,11 +151,23 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi @nostrmarket_ext.post("/api/v1/stall") async def api_create_stall( data: PartialStall, - wallet: WalletTypeInfo = Depends(require_invoice_key), -): + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Stall: try: + 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) - return stall.dict() + + 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 Exception as ex: logger.warning(ex) raise HTTPException( @@ -164,18 +179,23 @@ async def api_create_stall( @nostrmarket_ext.put("/api/v1/stall/{stall_id}") async def api_update_stall( data: Stall, - wallet: WalletTypeInfo = Depends(require_invoice_key), -): + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Stall: try: - stall = await get_stall(wallet.wallet.user, data.id) - if not stall: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, - detail="Stall does not exist.", - ) - stall = await update_stall(wallet.wallet.user, data.id, **data.dict()) - assert stall, "Cannot fetch updated stall" - return stall.dict() + 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.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 Exception as ex: @@ -207,7 +227,7 @@ async def api_get_stall(stall_id: str, wallet: WalletTypeInfo = Depends(get_key_ @nostrmarket_ext.get("/api/v1/stall") -async def api_gey_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): +async def api_get_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): try: stalls = await get_stalls(wallet.wallet.user) return stalls @@ -219,6 +239,38 @@ async def api_gey_stalls(wallet: WalletTypeInfo = Depends(get_key_type)): ) +@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 delte stall", + ) + + ######################################## OTHER ########################################