feat: publish event on C_UD for stalls

This commit is contained in:
Vlad Stan 2023-03-02 10:14:11 +02:00
parent aba3706a71
commit 5972c44ad1
7 changed files with 305 additions and 40 deletions

47
crud.py
View file

@ -1,4 +1,5 @@
import json import json
import time
from typing import List, Optional from typing import List, Optional
from lnbits.helpers import urlsafe_short_hash 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() zone_id = urlsafe_short_hash()
await db.execute( await db.execute(
f""" f"""
INSERT INTO nostrmarket.zones ( INSERT INTO nostrmarket.zones (id, user_id, name, currency, cost, regions)
id,
user_id,
name,
currency,
cost,
regions
)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", """,
( (
@ -112,6 +105,7 @@ async def delete_zone(zone_id: str) -> None:
async def create_stall(user_id: str, data: PartialStall) -> Stall: async def create_stall(user_id: str, data: PartialStall) -> Stall:
stall_id = urlsafe_short_hash() stall_id = urlsafe_short_hash()
await db.execute( await db.execute(
f""" f"""
INSERT INTO nostrmarket.stalls (user_id, id, wallet, name, currency, zones, meta) 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.wallet,
data.name, data.name,
data.currency, data.currency,
json.dumps(data.shipping_zones), json.dumps(
json.dumps(dict(data.config)), [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] return [Stall.from_row(row) for row in rows]
async def update_stall(user_id: str, stall_id: str, **kwargs) -> Optional[Stall]: async def update_stall(user_id: str, stall: Stall) -> Optional[Stall]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE market.stalls SET {q} WHERE user_id = ? AND id = ?", f"""
(*kwargs.values(), user_id, stall_id), 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( return await get_stall(user_id, stall.id)
"SELECT * FROM market.stalls WHERE user_id =? AND 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, user_id,
stall_id, stall_id,
), ),
) )
return Stall.from_row(row) if row else None

81
helpers.py Normal file
View file

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

View file

@ -1,10 +1,13 @@
import json import json
import time
from sqlite3 import Row from sqlite3 import Row
from typing import List, Optional from typing import List, Optional
from fastapi import Query
from pydantic import BaseModel from pydantic import BaseModel
from .helpers import sign_message_hash
from .nostr.event import NostrEvent
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
class MerchantConfig(BaseModel): class MerchantConfig(BaseModel):
@ -20,6 +23,9 @@ class PartialMerchant(BaseModel):
class Merchant(PartialMerchant): class Merchant(PartialMerchant):
id: str id: str
def sign_hash(self, hash: bytes) -> str:
return sign_message_hash(self.private_key, hash)
@classmethod @classmethod
def from_row(cls, row: Row) -> "Merchant": def from_row(cls, row: Row) -> "Merchant":
merchant = cls(**dict(row)) merchant = cls(**dict(row))
@ -49,24 +55,57 @@ class Zone(PartialZone):
class StallConfig(BaseModel): class StallConfig(BaseModel):
"""Last published nostr event id for this Stall"""
event_id: Optional[str]
image_url: Optional[str] image_url: Optional[str]
fiat_base_multiplier: int = 1 # todo: reminder wht is this for? description: Optional[str]
class PartialStall(BaseModel): class PartialStall(BaseModel):
wallet: str wallet: str
name: str name: str
currency: str = "sat" currency: str = "sat"
shipping_zones: List[str] = [] shipping_zones: List[PartialZone] = []
config: StallConfig = StallConfig() config: StallConfig = StallConfig()
class Stall(PartialStall): class Stall(PartialStall):
id: str 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 @classmethod
def from_row(cls, row: Row) -> "Stall": def from_row(cls, row: Row) -> "Stall":
stall = cls(**dict(row)) stall = cls(**dict(row))
stall.config = StallConfig(**json.loads(row["meta"])) 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 return stall

57
nostr/event.py Normal file
View file

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

21
nostr/nostr_client.py Normal file
View file

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

View file

@ -30,7 +30,7 @@ async function stallList(path) {
name: this.stallDialog.data.name, name: this.stallDialog.data.name,
wallet: this.stallDialog.data.wallet, wallet: this.stallDialog.data.wallet,
currency: this.stallDialog.data.currency, currency: this.stallDialog.data.currency,
shipping_zones: this.stallDialog.data.shippingZones.map(z => z.id), shipping_zones: this.stallDialog.data.shippingZones,
config: {} config: {}
}) })
}, },
@ -71,10 +71,14 @@ async function stallList(path) {
'/nostrmarket/api/v1/zone', '/nostrmarket/api/v1/zone',
this.inkey this.inkey
) )
console.log('### zones', data)
this.zoneOptions = data.map(z => ({ this.zoneOptions = data.map(z => ({
id: z.id, ...z,
label: `${z.name} (${z.countries.join(', ')})` label: z.name
? `${z.name} (${z.countries.join(', ')}})`
: z.countries.join(', ')
})) }))
console.log('### this.zoneOptions', this.zoneOptions)
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
} }

View file

@ -1,3 +1,4 @@
import json
from http import HTTPStatus from http import HTTPStatus
from typing import List, Optional from typing import List, Optional
@ -18,6 +19,7 @@ from .crud import (
create_merchant, create_merchant,
create_stall, create_stall,
create_zone, create_zone,
delete_stall,
delete_zone, delete_zone,
get_merchant_for_user, get_merchant_for_user,
get_stall, get_stall,
@ -28,6 +30,7 @@ from .crud import (
update_zone, update_zone,
) )
from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone from .models import Merchant, PartialMerchant, PartialStall, PartialZone, Stall, Zone
from .nostr.nostr_client import publish_nostr_event
######################################## MERCHANT ######################################## ######################################## MERCHANT ########################################
@ -148,11 +151,23 @@ async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admi
@nostrmarket_ext.post("/api/v1/stall") @nostrmarket_ext.post("/api/v1/stall")
async def api_create_stall( async def api_create_stall(
data: PartialStall, data: PartialStall,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ) -> Stall:
try: 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) 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: except Exception as ex:
logger.warning(ex) logger.warning(ex)
raise HTTPException( raise HTTPException(
@ -164,18 +179,23 @@ async def api_create_stall(
@nostrmarket_ext.put("/api/v1/stall/{stall_id}") @nostrmarket_ext.put("/api/v1/stall/{stall_id}")
async def api_update_stall( async def api_update_stall(
data: Stall, data: Stall,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_admin_key),
): ) -> Stall:
try: try:
stall = await get_stall(wallet.wallet.user, data.id) merchant = await get_merchant_for_user(wallet.wallet.user)
if not stall: assert merchant, "Cannot find merchat for stall"
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, event = data.to_nostr_event(merchant.public_key)
detail="Stall does not exist.", event.sig = merchant.sign_hash(bytes.fromhex(event.id))
)
stall = await update_stall(wallet.wallet.user, data.id, **data.dict()) data.config.event_id = event.id
assert stall, "Cannot fetch updated stall" # data.config.created_at =
return stall.dict() stall = await update_stall(wallet.wallet.user, data)
assert stall, "Cannot update stall"
await publish_nostr_event(event)
return stall
except HTTPException as ex: except HTTPException as ex:
raise ex raise ex
except Exception as 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") @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: try:
stalls = await get_stalls(wallet.wallet.user) stalls = await get_stalls(wallet.wallet.user)
return stalls 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 ######################################## ######################################## OTHER ########################################