diff --git a/.gitignore b/.gitignore index a56cf52..7fe0101 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ logs sqlStorage playground alembic.ini +.DS_Store diff --git a/app/api/__init__.py b/app/api/__init__.py index 349fbfd..2d5e36d 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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.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.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, "/") @@ -31,7 +32,8 @@ app.add_route(s_api_v1_storage_get, "/api/v1/storage/", methods=["GET 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) diff --git a/app/api/routes/_blockchain.py b/app/api/routes/_blockchain.py new file mode 100644 index 0000000..5e16143 --- /dev/null +++ b/app/api/routes/_blockchain.py @@ -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"}) diff --git a/app/api/routes/custodial.py b/app/api/routes/custodial.py deleted file mode 100644 index dda3357..0000000 --- a/app/api/routes/custodial.py +++ /dev/null @@ -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 - }) diff --git a/app/api/routes/node_storage.py b/app/api/routes/node_storage.py index d1579af..c4fa60a 100644 --- a/app/api/routes/node_storage.py +++ b/app/api/routes/node_storage.py @@ -1,10 +1,13 @@ from sanic import response from app.core._config import UPLOADS_DIR 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.logger import make_log from datetime import datetime from base58 import b58encode, b58decode +from mimetypes import guess_type import os import hashlib @@ -22,13 +25,21 @@ async def s_api_v1_storage_post(request): else: 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: file_hash = b58encode(hashlib.sha256(file_content).digest()).decode() new_content = StoredContent( hash=file_hash, filename=file_name, user_id=None, - meta={}, + meta=file_meta, created=datetime.now(), key_id=None ) @@ -39,19 +50,30 @@ async def s_api_v1_storage_post(request): with open(file_path, "wb") as file: 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: return response.json({"error": f"Error: {e}"}, status=500) -async def s_api_v1_storage_get(request, file_hash): - content = request.ctx.db_session.query(StoredContent).filter(StoredContent.hash == file_hash).first() +async def s_api_v1_storage_get(request, content_id): + 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: return response.json({"error": "File not found"}, status=404) - make_log(f"File {file_hash} requested by {request.ip}") - - file_path = os.path.join(UPLOADS_DIR, file_hash) + make_log(f"File {content_sha256} requested by {request.ip}") + file_path = os.path.join(UPLOADS_DIR, content_sha256) if not os.path.exists(file_path): return response.json({"error": "File not found"}, status=404) diff --git a/app/bot/routers/tonconnect.py b/app/bot/routers/tonconnect.py index 4dd8d7c..974324a 100644 --- a/app/bot/routers/tonconnect.py +++ b/app/bot/routers/tonconnect.py @@ -30,6 +30,7 @@ async def t_tonconnect_dev_menu(message: types.Message, memory=None, user=None, keyboard = [] 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() make_log("TonConnect_DevMenu", f"SDK connected?: {ton_connect.connected}", level='info') if not ton_connect.connected: @@ -63,4 +64,6 @@ Use /dev_tonconnect {wallet_app_name} for connect to wallet.""" 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')) diff --git a/app/core/_config.py b/app/core/_config.py index 0e8e15a..7196831 100644 --- a/app/core/_config.py +++ b/app/core/_config.py @@ -28,3 +28,9 @@ LOG_FILEPATH = f"{LOG_DIR}/{_now_str}.log" WEB_APP_URLS = { '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', +] diff --git a/app/core/_utils/resolve_content.py b/app/core/_utils/resolve_content.py new file mode 100644 index 0000000..7e703eb --- /dev/null +++ b/app/core/_utils/resolve_content.py @@ -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}" + diff --git a/app/core/_utils/string_binary.py b/app/core/_utils/string_binary.py new file mode 100644 index 0000000..b047824 --- /dev/null +++ b/app/core/_utils/string_binary.py @@ -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') diff --git a/app/core/content/__init__.py b/app/core/content/__init__.py new file mode 100644 index 0000000..5ea4310 --- /dev/null +++ b/app/core/content/__init__.py @@ -0,0 +1 @@ +from app.core.content.content_id import ContentId \ No newline at end of file diff --git a/app/core/content/content_id.py b/app/core/content/content_id.py new file mode 100644 index 0000000..3744cac --- /dev/null +++ b/app/core/content/content_id.py @@ -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") + + diff --git a/app/core/models/node_storage.py b/app/core/models/node_storage.py index 8cdf90d..2293958 100644 --- a/app/core/models/node_storage.py +++ b/app/core/models/node_storage.py @@ -4,12 +4,7 @@ from sqlalchemy.orm import relationship from .base import AlchemyBase from hashlib import sha256 from base58 import b58encode, b58decode - - -# DMY CID v1 specs -# 1. int8 cid version -# 2. int256 sha256 of content -# 3. int128 onchain content index +from app.core.content.content_id import ContentId class StoredContent(AlchemyBase): @@ -37,13 +32,10 @@ class StoredContent(AlchemyBase): user = relationship('User', uselist=False, foreign_keys=[user_id]) key = relationship('KnownKey', uselist=False, foreign_keys=[key_id]) - def make_dmy_cid_v1(self) -> str: - vhash = sha256( - (1).to_bytes(1, 'big') # cid version - + b58decode(hash) - + (self.onchain_index or 0).to_bytes(16, 'big') - ).digest() - return b58encode(vhash).decode() - - def make_deeplink(self): - return f"dmy://storage?cid={self.make_dmy_cid_v1()}" + @property + def cid(self) -> ContentId: + return ContentId( + content_hash=b58decode(self.hash), + onchain_index=self.onchain_index, + accept_type=self.meta.get('content_type', 'image/jpeg') + ) diff --git a/docs/web2-client.md b/docs/web2-client.md new file mode 100644 index 0000000..a29d579 --- /dev/null +++ b/docs/web2-client.md @@ -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" +} +``` \ No newline at end of file diff --git a/docs/web2-client_task280224.md b/docs/web2-client_task280224.md new file mode 100644 index 0000000..4a8f8ca --- /dev/null +++ b/docs/web2-client_task280224.md @@ -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 \ No newline at end of file