dev@locazia: add blockchain routes, edit resolve content system to cid class, add web2 docs

This commit is contained in:
user 2024-02-28 23:29:18 +03:00
parent 78a4a84c2b
commit d7ebc26bce
14 changed files with 314 additions and 61 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@ logs
sqlStorage sqlStorage
playground playground
alembic.ini alembic.ini
.DS_Store

View File

@ -16,7 +16,8 @@ from app.api.routes.auth import s_api_v1_auth_twa
from app.api.routes.tonconnect import s_api_tonconnect_manifest from app.api.routes.tonconnect import s_api_tonconnect_manifest
from app.api.routes.node_storage import s_api_v1_storage_post, s_api_v1_storage_get from app.api.routes.node_storage import s_api_v1_storage_post, s_api_v1_storage_get
from app.api.routes.account import s_api_v1_account_get from app.api.routes.account import s_api_v1_account_get
from app.api.routes.custodial import s_api_v1_custodial_upload_content from app.api.routes._blockchain import s_api_v1_blockchain_send_new_content_message, \
s_api_v1_blockchain_send_purchase_content_message
app.add_route(s_index, "/") app.add_route(s_index, "/")
@ -31,7 +32,8 @@ app.add_route(s_api_v1_storage_get, "/api/v1/storage/<file_hash>", methods=["GET
app.add_route(s_api_v1_account_get, "/api/v1/account", methods=["GET", "OPTIONS"]) app.add_route(s_api_v1_account_get, "/api/v1/account", methods=["GET", "OPTIONS"])
app.add_route(s_api_v1_custodial_upload_content, "/api/v1/custodial.uploadContent", methods=["POST", "OPTIONS"]) app.add_route(s_api_v1_blockchain_send_new_content_message, "/api/v1/blockchain.sendNewContentMessage", methods=["POST", "OPTIONS"])
app.add_route(s_api_v1_blockchain_send_purchase_content_message, "/api/v1/blockchain.sendPurchaseContentMessage", methods=["POST", "OPTIONS"])
@app.exception(BaseException) @app.exception(BaseException)

View File

@ -0,0 +1,47 @@
from sanic import response
def valid_royalty_params(royalty_params):
assert sum([x['value'] for x in royalty_params]) == 10000, "Values of royalties should sum to 10000"
for field_key, field_value in {
'address': lambda x: isinstance(x, str),
'value': lambda x: (isinstance(x, int) and 0 <= x <= 10000)
}.items():
assert field_key in royalty_params, f"No {field_key} provided"
assert field_value(royalty_params[field_key]), f"Invalid {field_key} provided"
return True
async def s_api_v1_blockchain_send_new_content_message(request):
assert request.json, "No data provided"
for field_key, field_value in {
'title': lambda x: isinstance(x, str),
'authors': lambda x: isinstance(x, list),
'content': lambda x: isinstance(x, str),
'image': lambda x: isinstance(x, str),
'description': lambda x: isinstance(x, str),
'price': lambda x: (isinstance(x, str) and x.isdigit()),
'allowResale': lambda x: isinstance(x, bool),
'royaltyParams': lambda x: (isinstance(x, dict) and valid_royalty_params(x)),
}.items():
assert field_key in request.json, f"No {field_key} provided"
assert field_value(request.json[field_key]), f"Invalid {field_key} provided"
assert request.json.get("title"), "No title provided"
return response.json({"message": "Transaction requested"})
async def s_api_v1_blockchain_send_purchase_content_message(request):
assert request.json, "No data provided"
for field_key, field_value in {
'content_address': lambda x: isinstance(x, str),
'price': lambda x: (isinstance(x, str) and x.isdigit()),
}.items():
assert field_key in request.json, f"No {field_key} provided"
assert field_value(request.json[field_key]), f"Invalid {field_key} provided"
return response.json({"message": "Transaction requested"})

View File

@ -1,36 +0,0 @@
from sanic import response
from app.core._config import TELEGRAM_API_KEY
from app.core.models.user import User
from app.core.logger import make_log
from datetime import datetime
import os
import hashlib
async def s_api_v1_custodial_upload_content(request):
if not request.json:
return response.json({"error": "No data provided"}, status=400)
if not request.json.get('content'):
return response.json({"error": "No content provided"}, status=400)
if not request.json.get('content_hash'):
return response.json({"error": "No content hash provided"}, status=400)
content = request.json['content']
content_hash = request.json['content_hash']
known_user = request.ctx.db_session.query(User).filter(User.telegram_id == request.ctx.user.telegram_id).first()
if not known_user:
return response.json({"error": "User not found"}, status=400)
content_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), f"../../../content/{content_hash}")
if os.path.exists(content_path):
return response.json({"error": "Content already exists"}, status=400)
with open(content_path, "wb") as f:
f.write(content)
return response.json({
'content_hash': content_hash
})

View File

@ -1,10 +1,13 @@
from sanic import response from sanic import response
from app.core._config import UPLOADS_DIR from app.core._config import UPLOADS_DIR
from app.core.storage import db_session from app.core.storage import db_session
from app.core.content.content_id import ContentId
from app.core._utils.resolve_content import resolve_content
from app.core.models.node_storage import StoredContent from app.core.models.node_storage import StoredContent
from app.core.logger import make_log from app.core.logger import make_log
from datetime import datetime from datetime import datetime
from base58 import b58encode, b58decode from base58 import b58encode, b58decode
from mimetypes import guess_type
import os import os
import hashlib import hashlib
@ -22,13 +25,21 @@ async def s_api_v1_storage_post(request):
else: else:
return response.json({"error": "No file provided"}, status=400) return response.json({"error": "No file provided"}, status=400)
file_meta = {}
file_mimetype, file_encoding = guess_type(file_name)[0]
if file_mimetype:
file_meta["content_type"] = file_mimetype
if file_encoding:
file_meta["extension_encoding"] = file_encoding
try: try:
file_hash = b58encode(hashlib.sha256(file_content).digest()).decode() file_hash = b58encode(hashlib.sha256(file_content).digest()).decode()
new_content = StoredContent( new_content = StoredContent(
hash=file_hash, hash=file_hash,
filename=file_name, filename=file_name,
user_id=None, user_id=None,
meta={}, meta=file_meta,
created=datetime.now(), created=datetime.now(),
key_id=None key_id=None
) )
@ -39,19 +50,30 @@ async def s_api_v1_storage_post(request):
with open(file_path, "wb") as file: with open(file_path, "wb") as file:
file.write(file_content) file.write(file_content)
return response.json({"content_sha256": file_hash}) new_content_id = new_content.cid
new_cid = new_content_id.serialize_v1()
return response.json({
"content_sha256": file_hash,
"content_id_v1": new_cid,
"content_url": f"dmy://storage?cid={new_cid}",
})
except BaseException as e: except BaseException as e:
return response.json({"error": f"Error: {e}"}, status=500) return response.json({"error": f"Error: {e}"}, status=500)
async def s_api_v1_storage_get(request, file_hash): async def s_api_v1_storage_get(request, content_id):
content = request.ctx.db_session.query(StoredContent).filter(StoredContent.hash == file_hash).first() cid, errmsg = resolve_content(content_id)
if errmsg:
return response.json({"error": errmsg}, status=400)
content_sha256 = b58encode(cid.content_hash).decode()
content = request.ctx.db_session.query(StoredContent).filter(StoredContent.hash == content_sha256).first()
if not content: if not content:
return response.json({"error": "File not found"}, status=404) return response.json({"error": "File not found"}, status=404)
make_log(f"File {file_hash} requested by {request.ip}") make_log(f"File {content_sha256} requested by {request.ip}")
file_path = os.path.join(UPLOADS_DIR, content_sha256)
file_path = os.path.join(UPLOADS_DIR, file_hash)
if not os.path.exists(file_path): if not os.path.exists(file_path):
return response.json({"error": "File not found"}, status=404) return response.json({"error": "File not found"}, status=404)

View File

@ -30,6 +30,7 @@ async def t_tonconnect_dev_menu(message: types.Message, memory=None, user=None,
keyboard = [] keyboard = []
ton_connect, ton_connection = TonConnect.by_user(db_session, user, callback_fn=()) ton_connect, ton_connection = TonConnect.by_user(db_session, user, callback_fn=())
make_log("TonConnect_DevMenu", f"Available wallets: {ton_connect._sdk_client.get_wallets()}", level='debug')
await ton_connect.restore_connection() await ton_connect.restore_connection()
make_log("TonConnect_DevMenu", f"SDK connected?: {ton_connect.connected}", level='info') make_log("TonConnect_DevMenu", f"SDK connected?: {ton_connect.connected}", level='info')
if not ton_connect.connected: if not ton_connect.connected:
@ -63,4 +64,6 @@ Use /dev_tonconnect <code>{wallet_app_name}</code> for connect to wallet."""
keyboard=get_inline_keyboard(keyboard) if keyboard else None keyboard=get_inline_keyboard(keyboard) if keyboard else None
) )
# async def t_tonconnect_wallets_list()
router.message.register(t_tonconnect_dev_menu, Command('dev_tonconnect')) router.message.register(t_tonconnect_dev_menu, Command('dev_tonconnect'))

View File

@ -28,3 +28,9 @@ LOG_FILEPATH = f"{LOG_DIR}/{_now_str}.log"
WEB_APP_URLS = { WEB_APP_URLS = {
'uploadContent': f"https://web2-client.vercel.app/uploadContent" 'uploadContent': f"https://web2-client.vercel.app/uploadContent"
} }
ALLOWED_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'video/mp4', 'video/webm', 'video/ogg',
'audio/mpeg', 'audio/ogg', 'audio/wav',
]

View File

@ -0,0 +1,9 @@
from app.core.content.content_id import ContentId
def resolve_content(content_id): # -> [content, error]
try:
return ContentId.deserialize(content_id), None
except BaseException as e:
return None, f"{e}"

View File

@ -0,0 +1,11 @@
def string_to_bytes_fixed_size(src: str, size: int, encoding='utf-8') -> bytes:
assert type(src) is str, "src must be a string"
src_bin = src.encode(encoding)
assert len(src_bin) <= size, "src is too long"
return src_bin + b'\x00' * (size - len(src_bin))
def bytes_to_string(src: bytes, encoding='utf-8') -> str:
assert type(src) is bytes, "src must be bytes"
return src.decode(encoding).rstrip('\x00')

View File

@ -0,0 +1 @@
from app.core.content.content_id import ContentId

View File

@ -0,0 +1,68 @@
from app.core._utils.string_binary import string_to_bytes_fixed_size, bytes_to_string
from base58 import b58encode, b58decode
from app.core._config import ALLOWED_CONTENT_TYPES
# cid_v1#_ cid_version:int8 accept_type:uint120 content_sha256:uint256 onchain_index:uint128 = CIDv1;
class ContentId:
def __init__(
self,
content_hash: bytes = None, # only SHA256
onchain_index: int = None,
accept_type: str = 'image/jpeg'
):
self.content_hash = content_hash
self.onchain_index = onchain_index or -1
self.accept_type = accept_type
def serialize_v1(self) -> str:
at_bin = string_to_bytes_fixed_size(self.accept_type, 15)
assert len(self.content_hash) == 32, "Invalid hash length"
if self.onchain_index < 0:
oi_bin = b''
else:
oi_bin = self.onchain_index.to_bytes(16, 'big', signed=False)
assert len(oi_bin) == 16, "Invalid onchain_index"
return b58encode(
(1).to_bytes(1, 'big') # cid version
+ at_bin
+ self.content_hash
+ oi_bin
).decode()
@classmethod
def from_v1(cls, cid: str):
cid_bin = b58decode(cid)
(
cid_version,
accept_type,
content_sha256,
onchain_index
) = (
int.from_bytes(cid_bin[0:1], 'big'),
bytes_to_string(cid_bin[1:16]),
cid_bin[16:48],
int.from_bytes(cid_bin[48:], 'big') if len(cid_bin) > 48 else -1
)
assert cid_version == 1, "Invalid version"
content_type = accept_type.split('/')
assert '/'.join(content_type[0:2]) in ALLOWED_CONTENT_TYPES, "Invalid accept type"
assert len(content_sha256) == 32, "Invalid hash length"
return cls(
content_hash=content_sha256,
onchain_index=onchain_index,
accept_type=accept_type
)
@classmethod
def deserialize(cls, cid: str):
cid_version = int.from_bytes(b58decode(cid)[0:1], 'big')
if cid_version == 1:
return cls.from_v1(cid)
else:
raise ValueError("Invalid cid version")

View File

@ -4,12 +4,7 @@ from sqlalchemy.orm import relationship
from .base import AlchemyBase from .base import AlchemyBase
from hashlib import sha256 from hashlib import sha256
from base58 import b58encode, b58decode from base58 import b58encode, b58decode
from app.core.content.content_id import ContentId
# DMY CID v1 specs
# 1. int8 cid version
# 2. int256 sha256 of content
# 3. int128 onchain content index
class StoredContent(AlchemyBase): class StoredContent(AlchemyBase):
@ -37,13 +32,10 @@ class StoredContent(AlchemyBase):
user = relationship('User', uselist=False, foreign_keys=[user_id]) user = relationship('User', uselist=False, foreign_keys=[user_id])
key = relationship('KnownKey', uselist=False, foreign_keys=[key_id]) key = relationship('KnownKey', uselist=False, foreign_keys=[key_id])
def make_dmy_cid_v1(self) -> str: @property
vhash = sha256( def cid(self) -> ContentId:
(1).to_bytes(1, 'big') # cid version return ContentId(
+ b58decode(hash) content_hash=b58decode(self.hash),
+ (self.onchain_index or 0).to_bytes(16, 'big') onchain_index=self.onchain_index,
).digest() accept_type=self.meta.get('content_type', 'image/jpeg')
return b58encode(vhash).decode() )
def make_deeplink(self):
return f"dmy://storage?cid={self.make_dmy_cid_v1()}"

118
docs/web2-client.md Normal file
View File

@ -0,0 +1,118 @@
## Web2 Client (through HTTP API)
### API Public Endpoints
```text
https://music-gateway.letsw.app
/api/v1
```
### Telegram WebApp Authorization
[Implementation](../app/api/routes/auth.py)
#### Request (POST, /api/v1/auth.twa, JSON)
```javascript
{
twa_data: window.Telegram.WebApp.initData
}
```
#### Response (JSON)
```javascript
{
user: { ...User },
connected_wallet: null | {
version: string,
address: string,
ton_balance: string // nanoTON bignum
},
auth_v1_token: string
}
```
**Use** `auth_v1_token` as `Authorization` header for all authorized requests.
### Upload file
[Implementation](../app/api/routes/node_storage.py)
#### Request (POST, /api/v1/storage, FormData)
```javascript
{
file: File
}
```
#### Response (JSON)
```javascript
{
content_sha256: string,
content_id_v1: string,
content_url: string
}
```
### Download file
[Implementation](../app/api/routes/node_storage.py)
#### Request (GET, /api/v1/storage/:content_id)
#### Response (File)
### Create new content
[Implementation](../app/api/routes/blockchain.py)
#### Request (POST, /api/v1/blockchain.sendNewContentMessage, JSON)
```javascript
{
title: string,
authors: list,
content: string, // recommended dmy://
image: string, // recommended dmy://
description: string,
price: string, // nanoTON bignum
resaleLicensePrice: string // nanoTON bignum (default = 0)
allowResale: boolean,
royaltyParams: [{
address: string,
value: number // 10000 = 100%
}]
}
```
#### Response (JSON)
```javascript
{
message: "Transaction requested"
}
```
### Purchase content
[Implementation](../app/api/routes/blockchain.py)
#### Request (POST, /api/v1/blockchain.sendPurchaseContentMessage, JSON)
```javascript
{
content_address: string,
price: string // nanoTON bignum
}
```
#### Response (JSON)
```javascript
{
message: "Transaction requested"
}
```

View File

@ -0,0 +1,9 @@
## Web2 Client Task #280224
1. В процессе изменения дизайна сделать все элементы по нормальному в отличие от того как сейчас: чтобы страница состояла из компонентов, а не монолитно написана.
2. Сделать чтобы при нажатии на кнопку "Загрузить контент" открывалось окно с "Перейдите в кошелек, вы запросили транзакцию" и если сервер в дополнении к обычному message вернул еще и walletLink, то отобразить кнопку для перехода в кошелек
3. Чтобы запросить транзакцию, нужно отправить запрос `docs/web2-client/UploadFile` с файлом и получить в ответ content_url, который после загрузки изображения и самого контента нужно приложить в запрос `docs/web2-client/CreateNewContent` в поле image и content соответственно
4. Желательно: сделать отображение загруженной обложки в виде карточки с кнопкой "Удалить" и "Изменить" (при нажатии на изменить открывается окно загрузки контента)
5. Обработать чтобы контент проходил полную цепочку загрузки (загрузка изображения, загрузка контента, запрос транзакции через бэкенд) и после всего вебапп закрывался через window.Telegram.WebApp.close()
6. Сделать дизайн как хочет Миша
7. Обработать ситуацию когда кошелек не подключен, то есть в ответе на запрос `docs/web2-client/auth.twa` приходит connected_wallet: null