feat: publish event on C_UD for stalls
This commit is contained in:
parent
aba3706a71
commit
5972c44ad1
7 changed files with 305 additions and 40 deletions
47
crud.py
47
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
|
||||
|
|
|
|||
81
helpers.py
Normal file
81
helpers.py
Normal 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
|
||||
47
models.py
47
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
|
||||
|
|
|
|||
57
nostr/event.py
Normal file
57
nostr/event.py
Normal 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
21
nostr/nostr_client.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
82
views_api.py
82
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 ########################################
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue