update
This commit is contained in:
1 parent
274c8f1f09
commit
34d39a8580
35 files changed
+3761
-7102
No files matched your search
@@ -1,295 +0,0 @@
|
||||
from base64 import b64encode
|
||||
from datetime import datetime
|
||||
import traceback
|
||||
|
||||
from sanic import response
|
||||
from sqlalchemy import and_
|
||||
from tonsdk.boc import begin_cell, begin_dict
|
||||
from tonsdk.utils import Address
|
||||
|
||||
from base58 import b58encode
|
||||
from app.core._blockchain.ton.connect import TonConnect, wallet_obj_by_name
|
||||
from app.core._blockchain.ton.platform import platform
|
||||
from app.core._config import PROJECT_HOST
|
||||
from app.core.logger import make_log
|
||||
from app.core._utils.resolve_content import resolve_content
|
||||
from app.core.content.utils import create_metadata_for_item
|
||||
from app.core._crypto.content import create_encrypted_content
|
||||
from app.core.models.content.user_content import UserContent
|
||||
from app.core.models.node_storage import StoredContent
|
||||
from app.core.models._telegram import Wrapped_CBotChat
|
||||
from app.core._keyboards import get_inline_keyboard
|
||||
from app.core.models.promo import PromoAction
|
||||
from app.core.models.tasks import BlockchainTask
|
||||
|
||||
|
||||
def valid_royalty_params(royalty_params):
|
||||
assert sum([x['value'] for x in royalty_params]) == 10000, "Values of royalties should sum to 10000"
|
||||
for royalty_param in royalty_params:
|
||||
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_param, f"No {field_key} provided"
|
||||
assert field_value(royalty_param[field_key]), f"Invalid {field_key} provided"
|
||||
return True
|
||||
|
||||
|
||||
async def s_api_v1_blockchain_send_new_content_message(request):
|
||||
try:
|
||||
assert request.json, "No data provided"
|
||||
assert request.ctx.user, "No authorized user provided"
|
||||
|
||||
if not request.json['hashtags']:
|
||||
request.json['hashtags'] = []
|
||||
|
||||
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, list) and valid_royalty_params(x)),
|
||||
'hashtags': lambda x: isinstance(x, list) and all([isinstance(y, str) for y in 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"
|
||||
|
||||
decrypted_content_cid, err = resolve_content(request.json['content'])
|
||||
assert not err, f"Invalid content CID"
|
||||
|
||||
# Поиск исходного файла загруженного
|
||||
decrypted_content = request.ctx.db_session.query(StoredContent).filter(
|
||||
StoredContent.hash == decrypted_content_cid.content_hash_b58
|
||||
).first()
|
||||
assert decrypted_content, "No content locally found"
|
||||
assert decrypted_content.type == "local/content_bin", "Invalid content type"
|
||||
|
||||
# Создание фиктивного encrypted_content. Не шифруем для производительности, тк зашифрованная нигде дальше не используется
|
||||
encrypted_content = await create_encrypted_content(request.ctx.db_session, decrypted_content)
|
||||
encrypted_content_cid = encrypted_content.cid
|
||||
|
||||
if request.json['image']:
|
||||
image_content_cid, err = resolve_content(request.json['image'])
|
||||
assert not err, f"Invalid image CID"
|
||||
image_content = request.ctx.db_session.query(StoredContent).filter(
|
||||
StoredContent.hash == image_content_cid.content_hash_b58
|
||||
).first()
|
||||
assert image_content, "No image locally found"
|
||||
else:
|
||||
image_content_cid = None
|
||||
image_content = None
|
||||
|
||||
|
||||
content_title = f"{', '.join(request.json['authors'])} – {request.json['title']}" if request.json['authors'] else request.json['title']
|
||||
|
||||
metadata_content = await create_metadata_for_item(
|
||||
request.ctx.db_session,
|
||||
title=content_title,
|
||||
cover_url=f"{PROJECT_HOST}/api/v1.5/storage/{image_content_cid.serialize_v2()}" if image_content_cid else None,
|
||||
authors=request.json['authors'],
|
||||
hashtags=request.json['hashtags'],
|
||||
downloadable=request.json['downloadable'] if 'downloadable' in request.json else False,
|
||||
)
|
||||
|
||||
royalties_dict = begin_dict(8)
|
||||
i = 0
|
||||
for royalty_param in request.json['royaltyParams']:
|
||||
royalties_dict.store_ref(
|
||||
i, begin_cell()
|
||||
.store_address(Address(royalty_param['address']))
|
||||
.store_uint(royalty_param['value'], 16)
|
||||
.end_cell()
|
||||
)
|
||||
i += 1
|
||||
|
||||
promo_free_upload_available = (
|
||||
3 - (request.ctx.db_session.query(PromoAction).filter(
|
||||
PromoAction.user_internal_id == request.ctx.user.id,
|
||||
PromoAction.action_type == 'freeUpload',
|
||||
).count())
|
||||
)
|
||||
if request.ctx.db_session.query(BlockchainTask).filter(
|
||||
and_(
|
||||
BlockchainTask.user_id == request.ctx.user.id,
|
||||
BlockchainTask.status != 'done',
|
||||
)
|
||||
).first():
|
||||
make_log("Blockchain", f"User {request.ctx.user.id} already has a pending task", level='warning')
|
||||
promo_free_upload_available = 0
|
||||
|
||||
make_log("Blockchain", f"User {request.ctx.user.id} has {promo_free_upload_available} free uploads available", level='info')
|
||||
|
||||
if promo_free_upload_available > 0:
|
||||
promo_action = PromoAction(
|
||||
user_id = str(request.ctx.user.id),
|
||||
user_internal_id=request.ctx.user.id,
|
||||
action_type='freeUpload',
|
||||
action_ref=str(encrypted_content_cid.content_hash),
|
||||
created=datetime.now()
|
||||
)
|
||||
request.ctx.db_session.add(promo_action)
|
||||
|
||||
blockchain_task = BlockchainTask(
|
||||
destination=platform.address.to_string(1, 1, 1),
|
||||
amount=str(int(0.03 * 10 ** 9)),
|
||||
payload=b64encode(
|
||||
begin_cell()
|
||||
.store_uint(0x5491d08c, 32)
|
||||
.store_uint(int.from_bytes(encrypted_content_cid.content_hash, "big", signed=False), 256)
|
||||
.store_address(Address(request.ctx.user.wallet_address(request.ctx.db_session)))
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_coins(int(0))
|
||||
.store_coins(int(0))
|
||||
.store_coins(int(request.json['price']))
|
||||
.end_cell()
|
||||
)
|
||||
.store_maybe_ref(royalties_dict.end_dict())
|
||||
.store_uint(0, 1)
|
||||
.end_cell()
|
||||
)
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_bytes(f"{PROJECT_HOST}/api/v1.5/storage/{metadata_content.cid.serialize_v2(include_accept_type=True)}".encode())
|
||||
.end_cell()
|
||||
)
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_ref(begin_cell().store_bytes(f"{encrypted_content_cid.serialize_v2()}".encode()).end_cell())
|
||||
.store_ref(begin_cell().store_bytes(f"{image_content_cid.serialize_v2() if image_content_cid else ''}".encode()).end_cell())
|
||||
.store_ref(begin_cell().store_bytes(f"{metadata_content.cid.serialize_v2()}".encode()).end_cell())
|
||||
.end_cell()
|
||||
)
|
||||
.end_cell()
|
||||
)
|
||||
.end_cell().to_boc(False)
|
||||
).decode(),
|
||||
epoch=None, seqno=None,
|
||||
created = datetime.now(),
|
||||
status='wait',
|
||||
user_id = request.ctx.user.id
|
||||
)
|
||||
request.ctx.db_session.add(blockchain_task)
|
||||
request.ctx.db_session.commit()
|
||||
|
||||
await request.ctx.user_uploader_wrapper.send_message(
|
||||
request.ctx.user.translated('p_uploadContentTxPromo').format(
|
||||
title=content_title,
|
||||
free_count=(promo_free_upload_available - 1)
|
||||
), message_type='hint', message_meta={
|
||||
'encrypted_content_hash': b58encode(encrypted_content_cid.content_hash).decode(),
|
||||
'hint_type': 'uploadContentTxRequested'
|
||||
}
|
||||
)
|
||||
return response.json({
|
||||
'address': "free",
|
||||
'amount': str(int(0.03 * 10 ** 9)),
|
||||
'payload': ""
|
||||
})
|
||||
|
||||
await request.ctx.user_uploader_wrapper.send_message(
|
||||
request.ctx.user.translated('p_uploadContentTxRequested').format(
|
||||
title=content_title,
|
||||
), message_type='hint', message_meta={
|
||||
'encrypted_content_hash': b58encode(encrypted_content_cid.content_hash).decode(),
|
||||
'hint_type': 'uploadContentTxRequested'
|
||||
}
|
||||
)
|
||||
|
||||
return response.json({
|
||||
'address': platform.address.to_string(1, 1, 1),
|
||||
'amount': str(int(0.03 * 10 ** 9)),
|
||||
'payload': b64encode(
|
||||
begin_cell()
|
||||
.store_uint(0x5491d08c, 32)
|
||||
.store_uint(int.from_bytes(encrypted_content_cid.content_hash, "big", signed=False), 256)
|
||||
.store_uint(0, 2)
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_coins(int(0))
|
||||
.store_coins(int(0))
|
||||
.store_coins(int(request.json['price']))
|
||||
.end_cell()
|
||||
)
|
||||
.store_maybe_ref(royalties_dict.end_dict())
|
||||
.store_uint(0, 1)
|
||||
.end_cell()
|
||||
)
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_bytes(f"{PROJECT_HOST}/api/v1.5/storage/{metadata_content.cid.serialize_v2(include_accept_type=True)}".encode())
|
||||
.end_cell()
|
||||
)
|
||||
.store_ref(
|
||||
begin_cell()
|
||||
.store_ref(begin_cell().store_bytes(f"{encrypted_content_cid.serialize_v2()}".encode()).end_cell())
|
||||
.store_ref(begin_cell().store_bytes(f"{image_content_cid.serialize_v2() if image_content_cid else ''}".encode()).end_cell())
|
||||
.store_ref(begin_cell().store_bytes(f"{metadata_content.cid.serialize_v2()}".encode()).end_cell())
|
||||
.end_cell()
|
||||
)
|
||||
.end_cell()
|
||||
)
|
||||
.end_cell().to_boc(False)
|
||||
).decode()
|
||||
})
|
||||
except BaseException as e:
|
||||
make_log("Blockchain", f"Error while sending new content message: {e}" + '\n' + traceback.format_exc(), level='error')
|
||||
return response.json({"error": str(e)}, status=400)
|
||||
|
||||
|
||||
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),
|
||||
'license_type': lambda x: x in ['resale']
|
||||
}.items():
|
||||
assert field_key in request.json, f"No {field_key} provided"
|
||||
assert field_value(request.json[field_key]), f"Invalid {field_key} provided"
|
||||
|
||||
if not request.ctx.user.wallet_address(request.ctx.db_session):
|
||||
return response.json({"error": "No wallet address provided"}, status=400)
|
||||
|
||||
license_exist = request.ctx.db_session.query(UserContent).filter_by(
|
||||
onchain_address=request.json['content_address'],
|
||||
).first()
|
||||
if license_exist:
|
||||
r_content = StoredContent.from_cid(request.ctx.db_session, license_exist.content.cid.serialize_v2())
|
||||
else:
|
||||
r_content = StoredContent.from_cid(request.ctx.db_session, request.json['content_address'])
|
||||
|
||||
content = r_content.open_content(request.ctx.db_session)
|
||||
|
||||
licenses_cost = content['encrypted_content'].json_format()['license']
|
||||
assert request.json['license_type'] in licenses_cost
|
||||
|
||||
return response.json({
|
||||
'address': (
|
||||
license_exist.onchain_address if license_exist else content['encrypted_content'].json_format()['item_address']
|
||||
),
|
||||
'amount': str(int(licenses_cost['resale']['price'])),
|
||||
'payload': b64encode((
|
||||
begin_cell()
|
||||
.store_uint(0x2a319593, 32)
|
||||
.store_uint(0, 64)
|
||||
.store_uint(3, 8)
|
||||
# .store_uint({
|
||||
# 'listen': 1,
|
||||
# 'resale': 3
|
||||
# }[request.json['license_type']], 8)
|
||||
.store_uint(0, 256)
|
||||
.store_uint(0, 2)
|
||||
.end_cell()
|
||||
).to_boc(False)).decode()
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
from sanic import response
|
||||
|
||||
|
||||
async def s_index(request):
|
||||
return response.json({
|
||||
'success': True,
|
||||
'message': 'Welcome to the @MY API!'
|
||||
})
|
||||
|
||||
|
||||
async def s_favicon(request):
|
||||
return response.redirect(
|
||||
"https://git.projscale.dev/my-dev/assets/raw/commit/890ed9e60a25a65c8ad600d6d0ad3ac4480e3039/images/logo.png"
|
||||
)
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from base58 import b58encode, b58decode
|
||||
from sanic import response
|
||||
|
||||
from app.core.models.node_storage import StoredContent
|
||||
from app.core._blockchain.ton.platform import platform
|
||||
from app.core._crypto.signer import Signer
|
||||
from app.core._secrets import hot_pubkey, service_wallet, hot_seed
|
||||
from app.core.logger import make_log
|
||||
|
||||
|
||||
def get_git_info():
|
||||
branch_name = subprocess.check_output(["git", "branch", "--show-current"]).decode('utf-8').strip()
|
||||
commit_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode('utf-8').strip()
|
||||
return branch_name, commit_hash
|
||||
|
||||
|
||||
async def s_api_v1_node(request): # /api/v1/node
|
||||
last_known_index = request.ctx.db_session.query(StoredContent).filter(
|
||||
StoredContent.onchain_index != None
|
||||
).order_by(StoredContent.onchain_index.desc()).first()
|
||||
last_known_index = last_known_index.onchain_index if last_known_index else 0
|
||||
last_known_index = max(last_known_index, 0)
|
||||
return response.json({
|
||||
'id': b58encode(hot_pubkey).decode(),
|
||||
'node_address': service_wallet.address.to_string(1, 1, 1),
|
||||
'master_address': platform.address.to_string(1, 1, 1),
|
||||
'indexer_height': last_known_index,
|
||||
'services': {
|
||||
service_key: {
|
||||
'status': (service['status'] if (service['timestamp'] and (datetime.now() - service['timestamp']).total_seconds() < 30) else 'not working: timeout'),
|
||||
'delay': round((datetime.now() - service['timestamp']).total_seconds(), 3) if service['timestamp'] else -1,
|
||||
}
|
||||
for service_key, service in request.app.ctx.memory.known_states.items()
|
||||
}
|
||||
})
|
||||
|
||||
async def s_api_v1_node_friendly(request):
|
||||
last_known_index = request.ctx.db_session.query(StoredContent).filter(
|
||||
StoredContent.onchain_index != None
|
||||
).order_by(StoredContent.onchain_index.desc()).first()
|
||||
last_known_index = last_known_index.onchain_index if last_known_index else 0
|
||||
last_known_index = max(last_known_index, 0)
|
||||
response_plain_text = f"""
|
||||
Node address: {service_wallet.address.to_string(1, 1, 1)}
|
||||
Node ID: {b58encode(hot_pubkey).decode()}
|
||||
Master address: {platform.address.to_string(1, 1, 1)}
|
||||
Indexer height: {last_known_index}
|
||||
Services:
|
||||
"""
|
||||
for service_key, service in request.app.ctx.memory.known_states.items():
|
||||
response_plain_text += f"""
|
||||
{service_key}:
|
||||
status: {service['status'] if (service['timestamp'] and (datetime.now() - service['timestamp']).total_seconds() < 120) else 'not working: timeout'}
|
||||
delay: {round((datetime.now() - service['timestamp']).total_seconds(), 3) if service['timestamp'] else -1}
|
||||
"""
|
||||
return response.text(response_plain_text, content_type='text/plain')
|
||||
|
||||
|
||||
async def s_api_system_send_status(request):
|
||||
if not request.json:
|
||||
return response.json({'error': 'No data'}, status=400)
|
||||
|
||||
message = request.json.get('message', '')
|
||||
signature = request.json.get('signature', '')
|
||||
if not message or not signature:
|
||||
return response.json({'error': 'No message or signature'}, status=400)
|
||||
|
||||
message = b58decode(message)
|
||||
|
||||
signer = Signer(hot_seed)
|
||||
if not signer.verify(message, signature):
|
||||
return response.json({'error': 'Invalid signature'}, status=400)
|
||||
|
||||
message = json.loads(message)
|
||||
assert message.get('service') in request.app.ctx.memory.known_states, "Unknown service"
|
||||
request.app.ctx.memory.known_states[
|
||||
message['service']
|
||||
] = {
|
||||
'status': message['status'],
|
||||
'timestamp': datetime.now(),
|
||||
}
|
||||
make_log("Health", f"Service {message['service']} status: {message['status']}", level='info')
|
||||
return response.json({'message': 'Status received'})
|
||||
|
||||
|
||||
async def s_api_system_version(request):
|
||||
branch_name, commit_hash = get_git_info()
|
||||
return response.json({
|
||||
"codebase_hash": commit_hash,
|
||||
"codebase_branch": branch_name,
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
from sanic import response
|
||||
|
||||
|
||||
async def s_api_v1_account_get(request):
|
||||
if not request.ctx.user:
|
||||
return response.json({"error": "User not found"}, status=400)
|
||||
|
||||
return response.json(request.ctx.user.json_format())
|
||||
@@ -1,190 +0,0 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from aiogram.utils.web_app import safe_parse_webapp_init_data
|
||||
from sanic import response
|
||||
from sqlalchemy import select, and_
|
||||
from tonsdk.utils import Address
|
||||
|
||||
from app.core._config import TELEGRAM_API_KEY, CLIENT_TELEGRAM_API_KEY
|
||||
from app.core.logger import make_log
|
||||
from app.core.models import KnownKey, WalletConnection
|
||||
from app.core.models.user import User
|
||||
from pytonconnect.parsers import WalletInfo, Account, TonProof
|
||||
|
||||
|
||||
async def s_api_v1_auth_twa(request):
|
||||
auth_data = {}
|
||||
for req_key in ['twa_data', 'ton_proof', 'ref_id']:
|
||||
try:
|
||||
auth_data[req_key] = request.json[req_key]
|
||||
except:
|
||||
auth_data[req_key] = None
|
||||
|
||||
twa_data = auth_data['twa_data']
|
||||
|
||||
valid_twa_data = False
|
||||
for validation_api_key in [TELEGRAM_API_KEY, CLIENT_TELEGRAM_API_KEY]:
|
||||
try:
|
||||
twa_data = safe_parse_webapp_init_data(token=validation_api_key, init_data=twa_data)
|
||||
assert twa_data
|
||||
valid_twa_data = True
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
if not valid_twa_data:
|
||||
make_log("auth", "Invalid TWA data", level="warning")
|
||||
return response.json({"error": "Invalid TWA data"}, status=401)
|
||||
|
||||
known_user = request.ctx.db_session.query(User).filter(User.telegram_id == twa_data.user.id).first()
|
||||
if not known_user:
|
||||
new_user = User(
|
||||
telegram_id=twa_data.user.id,
|
||||
username=twa_data.user.username,
|
||||
meta={
|
||||
"first_name": twa_data.user.first_name,
|
||||
"last_name": twa_data.user.last_name,
|
||||
"photo_url": twa_data.user.photo_url
|
||||
},
|
||||
lang_code=twa_data.user.language_code,
|
||||
last_use=datetime.now(),
|
||||
created=datetime.now()
|
||||
)
|
||||
request.ctx.db_session.add(new_user)
|
||||
request.ctx.db_session.commit()
|
||||
|
||||
known_user = request.ctx.db_session.query(User).filter(User.telegram_id == twa_data.user.id).first()
|
||||
assert known_user, "User not created"
|
||||
|
||||
new_user_key = await known_user.create_api_token_v1(request.ctx.db_session, "USER_API_V1")
|
||||
if auth_data['ton_proof']:
|
||||
try:
|
||||
wallet_info = WalletInfo()
|
||||
auth_data['ton_proof']['account']['network'] = auth_data['ton_proof']['account']['chain']
|
||||
wallet_info.account = Account.from_dict(auth_data['ton_proof']['account'])
|
||||
wallet_info.ton_proof = TonProof.from_dict({'proof': auth_data['ton_proof']['ton_proof']})
|
||||
connection_payload = auth_data['ton_proof']['ton_proof']['payload']
|
||||
known_payload = (request.ctx.db_session.execute(select(KnownKey).where(KnownKey.seed == connection_payload))).scalars().first()
|
||||
assert known_payload, "Unknown payload"
|
||||
assert known_payload.meta['I_user_id'] == known_user.id, "Invalid user_id"
|
||||
assert wallet_info.check_proof(connection_payload), "Invalid proof"
|
||||
|
||||
for known_connection in (request.ctx.db_session.execute(select(WalletConnection).where(
|
||||
and_(
|
||||
WalletConnection.user_id == known_user.id,
|
||||
WalletConnection.network == 'ton'
|
||||
)
|
||||
))).scalars().all():
|
||||
known_connection.invalidated = True
|
||||
|
||||
for other_connection in (request.ctx.db_session.execute(select(WalletConnection).where(
|
||||
WalletConnection.wallet_address == Address(wallet_info.account.address).to_string(1, 1, 1)
|
||||
))).scalars().all():
|
||||
other_connection.invalidated = True
|
||||
|
||||
new_connection = WalletConnection(
|
||||
user_id=known_user.id,
|
||||
network='ton',
|
||||
wallet_key='web2-client==1',
|
||||
connection_id=connection_payload,
|
||||
wallet_address=Address(wallet_info.account.address).to_string(1, 1, 1),
|
||||
keys={
|
||||
'ton_proof': auth_data['ton_proof']
|
||||
},
|
||||
meta={},
|
||||
created=datetime.now(),
|
||||
updated=datetime.now(),
|
||||
invalidated=False,
|
||||
without_pk=False
|
||||
)
|
||||
request.ctx.db_session.add(new_connection)
|
||||
request.ctx.db_session.commit()
|
||||
except BaseException as e:
|
||||
make_log("auth", f"Invalid ton_proof: {e}", level="warning")
|
||||
return response.json({"error": "Invalid ton_proof"}, status=400)
|
||||
|
||||
ton_connection = (request.ctx.db_session.execute(select(WalletConnection).where(
|
||||
and_(
|
||||
WalletConnection.user_id == known_user.id,
|
||||
WalletConnection.network == 'ton',
|
||||
WalletConnection.invalidated == False
|
||||
)
|
||||
).order_by(WalletConnection.created.desc()))).scalars().first()
|
||||
known_user.last_use = datetime.now()
|
||||
request.ctx.db_session.commit()
|
||||
|
||||
return response.json({
|
||||
'user': known_user.json_format(),
|
||||
'connected_wallet': ton_connection.json_format() if ton_connection else None,
|
||||
'auth_v1_token': new_user_key['auth_v1_token']
|
||||
})
|
||||
|
||||
async def s_api_v1_auth_me(request):
|
||||
if not request.ctx.user:
|
||||
return response.json({"error": "Unauthorized"}, status=401)
|
||||
|
||||
ton_connection = (request.ctx.db_session.execute(
|
||||
select(WalletConnection).where(
|
||||
and_(
|
||||
WalletConnection.user_id == request.ctx.user.id,
|
||||
WalletConnection.network == 'ton',
|
||||
WalletConnection.invalidated == False
|
||||
)
|
||||
).order_by(WalletConnection.created.desc())
|
||||
)).scalars().first()
|
||||
|
||||
return response.json({
|
||||
'user': request.ctx.user.json_format(),
|
||||
'connected_wallet': ton_connection.json_format() if ton_connection else None
|
||||
})
|
||||
|
||||
async def s_api_v1_auth_select_wallet(request):
|
||||
if not request.ctx.user:
|
||||
return response.json({"error": "Unauthorized"}, status=401)
|
||||
|
||||
try:
|
||||
data = request.json
|
||||
except Exception as e:
|
||||
return response.json({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
if "wallet_address" not in data:
|
||||
return response.json({"error": "wallet_address is required"}, status=400)
|
||||
|
||||
# Convert raw wallet address to canonical format using Address from tonsdk.utils
|
||||
raw_addr = data["wallet_address"]
|
||||
canonical_address = Address(raw_addr).to_string(1, 1, 1)
|
||||
|
||||
db_session = request.ctx.db_session
|
||||
user = request.ctx.user
|
||||
|
||||
# Check if a WalletConnection already exists for this user with the given canonical wallet address
|
||||
existing_connection = db_session.query(WalletConnection).filter(
|
||||
WalletConnection.user_id == user.id,
|
||||
WalletConnection.wallet_address == canonical_address
|
||||
).first()
|
||||
|
||||
if not existing_connection:
|
||||
return response.json({"error": "Wallet connection not found"}, status=404)
|
||||
|
||||
saved_values = {
|
||||
'keys': existing_connection.keys,
|
||||
'meta': existing_connection.meta,
|
||||
'wallet_key': existing_connection.wallet_key,
|
||||
'connection_id': existing_connection.connection_id + uuid4().hex,
|
||||
'network': existing_connection.network,
|
||||
}
|
||||
|
||||
new_connection = WalletConnection(
|
||||
**saved_values,
|
||||
user_id=user.id,
|
||||
wallet_address=canonical_address,
|
||||
created=datetime.now(),
|
||||
updated=datetime.now(),
|
||||
invalidated=False,
|
||||
without_pk=False
|
||||
)
|
||||
db_session.add(new_connection)
|
||||
db_session.commit()
|
||||
|
||||
return response.empty(status=200)
|
||||
@@ -1,870 +0,0 @@
|
||||
"""
|
||||
Authentication and authorization routes with JWT tokens, user management, and security features.
|
||||
Provides user registration, login, token refresh, and account management with comprehensive validation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sanic import Blueprint, Request, response
|
||||
from sanic.response import JSONResponse
|
||||
from sqlalchemy import select, update, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import db_manager, get_cache_manager
|
||||
from app.core.logging import get_logger
|
||||
from app.core.models.user import User, UserSession, UserRole
|
||||
from app.core.security import (
|
||||
hash_password, verify_password, generate_access_token,
|
||||
verify_access_token, generate_refresh_token, generate_api_key,
|
||||
sanitize_input, generate_csrf_token
|
||||
)
|
||||
from app.api.middleware import require_auth, validate_request, rate_limit
|
||||
from app.core.validation import (
|
||||
UserRegistrationSchema, UserLoginSchema, UserUpdateSchema,
|
||||
ApiKeySchema
|
||||
)
|
||||
|
||||
# Initialize blueprint
|
||||
auth_bp = Blueprint("auth", url_prefix="/api/v1/auth")
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
@rate_limit(limit=5, window=3600) # 5 registrations per hour
|
||||
@validate_request(UserRegistrationSchema)
|
||||
async def register_user(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Register new user with comprehensive validation and security checks.
|
||||
|
||||
Args:
|
||||
request: Sanic request with user registration data
|
||||
|
||||
Returns:
|
||||
JSONResponse: Registration result with access tokens
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
client_ip = request.headers.get("X-Forwarded-For", request.remote_addr)
|
||||
|
||||
# Sanitize input data
|
||||
username = sanitize_input(data["username"])
|
||||
email = sanitize_input(data["email"])
|
||||
full_name = sanitize_input(data.get("full_name", ""))
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Check if username already exists
|
||||
username_stmt = select(User).where(User.username == username)
|
||||
username_result = await session.execute(username_stmt)
|
||||
if username_result.scalar_one_or_none():
|
||||
return response.json(
|
||||
{"error": "Username already exists", "code": "USERNAME_EXISTS"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
email_stmt = select(User).where(User.email == email)
|
||||
email_result = await session.execute(email_stmt)
|
||||
if email_result.scalar_one_or_none():
|
||||
return response.json(
|
||||
{"error": "Email already registered", "code": "EMAIL_EXISTS"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Check registration rate limiting by IP
|
||||
cache_manager = get_cache_manager()
|
||||
ip_reg_key = f"registration_ip:{client_ip}"
|
||||
ip_registrations = await cache_manager.get(ip_reg_key, default=0)
|
||||
|
||||
if ip_registrations >= 3: # Max 3 registrations per IP per day
|
||||
return response.json(
|
||||
{"error": "Too many registrations from this IP", "code": "IP_LIMIT_EXCEEDED"},
|
||||
status=429
|
||||
)
|
||||
|
||||
# Hash password
|
||||
password_hash = hash_password(data["password"])
|
||||
|
||||
# Create user
|
||||
new_user = User(
|
||||
id=uuid4(),
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
full_name=full_name,
|
||||
is_active=True,
|
||||
email_verified=False, # Require email verification
|
||||
registration_ip=client_ip,
|
||||
last_login_ip=client_ip,
|
||||
settings={"theme": "light", "notifications": True}
|
||||
)
|
||||
|
||||
session.add(new_user)
|
||||
await session.commit()
|
||||
await session.refresh(new_user)
|
||||
|
||||
# Assign default role
|
||||
default_role_stmt = select(UserRole).where(UserRole.name == "user")
|
||||
role_result = await session.execute(default_role_stmt)
|
||||
default_role = role_result.scalar_one_or_none()
|
||||
|
||||
if default_role:
|
||||
new_user.roles.append(default_role)
|
||||
await session.commit()
|
||||
|
||||
# Update IP registration counter
|
||||
await cache_manager.increment(ip_reg_key, ttl=86400) # 24 hours
|
||||
|
||||
# Generate tokens
|
||||
access_token = generate_access_token(
|
||||
{"user_id": str(new_user.id), "username": username},
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
refresh_token = generate_refresh_token(new_user.id)
|
||||
|
||||
# Create user session
|
||||
session_id = str(uuid4())
|
||||
csrf_token = generate_csrf_token(new_user.id, session_id)
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
user_session = UserSession(
|
||||
id=UUID(session_id),
|
||||
user_id=new_user.id,
|
||||
refresh_token_hash=hash_password(refresh_token[-32:]), # Hash last 32 chars
|
||||
ip_address=client_ip,
|
||||
user_agent=request.headers.get("User-Agent", ""),
|
||||
expires_at=datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
)
|
||||
session.add(user_session)
|
||||
await session.commit()
|
||||
|
||||
await logger.ainfo(
|
||||
"User registered successfully",
|
||||
user_id=str(new_user.id),
|
||||
username=username,
|
||||
email=email,
|
||||
ip=client_ip
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"message": "Registration successful",
|
||||
"user": {
|
||||
"id": str(new_user.id),
|
||||
"username": username,
|
||||
"email": email,
|
||||
"full_name": full_name,
|
||||
"created_at": new_user.created_at.isoformat()
|
||||
},
|
||||
"tokens": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
},
|
||||
"session": {
|
||||
"session_id": session_id,
|
||||
"csrf_token": csrf_token
|
||||
}
|
||||
}, status=201)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"User registration failed",
|
||||
username=data.get("username"),
|
||||
email=data.get("email"),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Registration failed", "code": "REGISTRATION_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
@rate_limit(limit=10, window=900) # 10 login attempts per 15 minutes
|
||||
@validate_request(UserLoginSchema)
|
||||
async def login_user(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Authenticate user and generate access tokens with security logging.
|
||||
|
||||
Args:
|
||||
request: Sanic request with login credentials
|
||||
|
||||
Returns:
|
||||
JSONResponse: Authentication result with tokens
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
username_or_email = sanitize_input(data["username"])
|
||||
password = data["password"]
|
||||
remember_me = data.get("remember_me", False)
|
||||
client_ip = request.headers.get("X-Forwarded-For", request.remote_addr)
|
||||
|
||||
# Check login rate limiting
|
||||
cache_manager = get_cache_manager()
|
||||
login_key = f"login_attempts:{username_or_email}:{client_ip}"
|
||||
attempts = await cache_manager.get(login_key, default=0)
|
||||
|
||||
if attempts >= 5: # Max 5 failed attempts
|
||||
return response.json(
|
||||
{"error": "Too many login attempts", "code": "LOGIN_BLOCKED"},
|
||||
status=429
|
||||
)
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Find user by username or email
|
||||
user_stmt = select(User).where(
|
||||
or_(User.username == username_or_email, User.email == username_or_email)
|
||||
).options(selectinload(User.roles))
|
||||
|
||||
user_result = await session.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
# Increment failed attempts
|
||||
await cache_manager.increment(login_key, ttl=900) # 15 minutes
|
||||
|
||||
await logger.awarning(
|
||||
"Failed login attempt",
|
||||
username=username_or_email,
|
||||
ip=client_ip,
|
||||
attempts=attempts + 1
|
||||
)
|
||||
|
||||
return response.json(
|
||||
{"error": "Invalid credentials", "code": "INVALID_CREDENTIALS"},
|
||||
status=401
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
return response.json(
|
||||
{"error": "Account deactivated", "code": "ACCOUNT_DEACTIVATED"},
|
||||
status=403
|
||||
)
|
||||
|
||||
# Successful login - clear failed attempts
|
||||
await cache_manager.delete(login_key)
|
||||
|
||||
# Update user login info
|
||||
user.last_login_at = datetime.utcnow()
|
||||
user.last_login_ip = client_ip
|
||||
user.login_count = (user.login_count or 0) + 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Generate tokens
|
||||
user_permissions = []
|
||||
for role in user.roles:
|
||||
user_permissions.extend(role.permissions)
|
||||
|
||||
token_payload = {
|
||||
"user_id": str(user.id),
|
||||
"username": user.username,
|
||||
"permissions": list(set(user_permissions)) # Remove duplicates
|
||||
}
|
||||
|
||||
expires_in = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
if remember_me:
|
||||
expires_in *= 24 # 24x longer for remember me
|
||||
|
||||
access_token = generate_access_token(token_payload, expires_in=expires_in)
|
||||
refresh_token = generate_refresh_token(user.id)
|
||||
|
||||
# Create user session
|
||||
session_id = str(uuid4())
|
||||
csrf_token = generate_csrf_token(user.id, session_id)
|
||||
|
||||
refresh_expires = timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
if remember_me:
|
||||
refresh_expires *= 2 # Longer refresh for remember me
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
user_session = UserSession(
|
||||
id=UUID(session_id),
|
||||
user_id=user.id,
|
||||
refresh_token_hash=hash_password(refresh_token[-32:]),
|
||||
ip_address=client_ip,
|
||||
user_agent=request.headers.get("User-Agent", ""),
|
||||
expires_at=datetime.utcnow() + refresh_expires,
|
||||
remember_me=remember_me
|
||||
)
|
||||
session.add(user_session)
|
||||
await session.commit()
|
||||
|
||||
await logger.ainfo(
|
||||
"User logged in successfully",
|
||||
user_id=str(user.id),
|
||||
username=user.username,
|
||||
ip=client_ip,
|
||||
remember_me=remember_me
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"message": "Login successful",
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"last_login": user.last_login_at.isoformat() if user.last_login_at else None,
|
||||
"permissions": user_permissions
|
||||
},
|
||||
"tokens": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": expires_in
|
||||
},
|
||||
"session": {
|
||||
"session_id": session_id,
|
||||
"csrf_token": csrf_token
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Login failed",
|
||||
username=data.get("username"),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Login failed", "code": "LOGIN_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@auth_bp.route("/refresh", methods=["POST"])
|
||||
@rate_limit(limit=50, window=3600) # 50 refresh attempts per hour
|
||||
async def refresh_tokens(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Refresh access token using refresh token with rotation.
|
||||
|
||||
Args:
|
||||
request: Sanic request with refresh token
|
||||
|
||||
Returns:
|
||||
JSONResponse: New access and refresh tokens
|
||||
"""
|
||||
try:
|
||||
refresh_token = request.json.get("refresh_token")
|
||||
if not refresh_token:
|
||||
return response.json(
|
||||
{"error": "Refresh token required", "code": "TOKEN_REQUIRED"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Verify refresh token
|
||||
payload = verify_access_token(refresh_token, token_type="refresh")
|
||||
if not payload:
|
||||
return response.json(
|
||||
{"error": "Invalid refresh token", "code": "INVALID_TOKEN"},
|
||||
status=401
|
||||
)
|
||||
|
||||
user_id = UUID(payload["user_id"])
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Verify session exists and is valid
|
||||
session_stmt = select(UserSession).where(
|
||||
and_(
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.refresh_token_hash == hash_password(refresh_token[-32:]),
|
||||
UserSession.expires_at > datetime.utcnow(),
|
||||
UserSession.is_active == True
|
||||
)
|
||||
)
|
||||
session_result = await session.execute(session_stmt)
|
||||
user_session = session_result.scalar_one_or_none()
|
||||
|
||||
if not user_session:
|
||||
return response.json(
|
||||
{"error": "Session expired or invalid", "code": "SESSION_INVALID"},
|
||||
status=401
|
||||
)
|
||||
|
||||
# Get user with permissions
|
||||
user_stmt = select(User).where(User.id == user_id).options(selectinload(User.roles))
|
||||
user_result = await session.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.is_active:
|
||||
return response.json(
|
||||
{"error": "User not found or inactive", "code": "USER_INACTIVE"},
|
||||
status=401
|
||||
)
|
||||
|
||||
# Generate new tokens (token rotation)
|
||||
user_permissions = []
|
||||
for role in user.roles:
|
||||
user_permissions.extend(role.permissions)
|
||||
|
||||
new_access_token = generate_access_token(
|
||||
{
|
||||
"user_id": str(user.id),
|
||||
"username": user.username,
|
||||
"permissions": list(set(user_permissions))
|
||||
},
|
||||
expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
new_refresh_token = generate_refresh_token(user.id)
|
||||
|
||||
# Update session with new refresh token
|
||||
user_session.refresh_token_hash = hash_password(new_refresh_token[-32:])
|
||||
user_session.last_used_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
await logger.adebug(
|
||||
"Tokens refreshed",
|
||||
user_id=str(user_id),
|
||||
session_id=str(user_session.id)
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"tokens": {
|
||||
"access_token": new_access_token,
|
||||
"refresh_token": new_refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror("Token refresh failed", error=str(e))
|
||||
return response.json(
|
||||
{"error": "Token refresh failed", "code": "REFRESH_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
@require_auth()
|
||||
async def logout_user(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Logout user and invalidate session.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: Logout confirmation
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
session_id = request.headers.get("X-Session-ID")
|
||||
|
||||
if session_id:
|
||||
async with db_manager.get_session() as session:
|
||||
# Invalidate specific session
|
||||
session_stmt = select(UserSession).where(
|
||||
and_(
|
||||
UserSession.id == UUID(session_id),
|
||||
UserSession.user_id == user_id
|
||||
)
|
||||
)
|
||||
session_result = await session.execute(session_stmt)
|
||||
user_session = session_result.scalar_one_or_none()
|
||||
|
||||
if user_session:
|
||||
user_session.is_active = False
|
||||
user_session.logged_out_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
await logger.ainfo(
|
||||
"User logged out",
|
||||
user_id=str(user_id),
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"message": "Logout successful",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Logout failed",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Logout failed", "code": "LOGOUT_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@auth_bp.route("/me", methods=["GET"])
|
||||
@require_auth()
|
||||
async def get_current_user(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Get current user information and permissions.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: Current user data
|
||||
"""
|
||||
try:
|
||||
user = request.ctx.user
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Get user with full details
|
||||
user_stmt = select(User).where(User.id == user.id).options(
|
||||
selectinload(User.roles),
|
||||
selectinload(User.api_keys)
|
||||
)
|
||||
user_result = await session.execute(user_stmt)
|
||||
full_user = user_result.scalar_one_or_none()
|
||||
|
||||
if not full_user:
|
||||
return response.json(
|
||||
{"error": "User not found", "code": "USER_NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
# Get user permissions
|
||||
permissions = []
|
||||
roles = []
|
||||
for role in full_user.roles:
|
||||
roles.append({
|
||||
"name": role.name,
|
||||
"description": role.description
|
||||
})
|
||||
permissions.extend(role.permissions)
|
||||
|
||||
# Get active sessions
|
||||
sessions_stmt = select(UserSession).where(
|
||||
and_(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.is_active == True,
|
||||
UserSession.expires_at > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
sessions_result = await session.execute(sessions_stmt)
|
||||
active_sessions = sessions_result.scalars().all()
|
||||
|
||||
return response.json({
|
||||
"user": {
|
||||
"id": str(full_user.id),
|
||||
"username": full_user.username,
|
||||
"email": full_user.email,
|
||||
"full_name": full_user.full_name,
|
||||
"bio": full_user.bio,
|
||||
"avatar_url": full_user.avatar_url,
|
||||
"is_active": full_user.is_active,
|
||||
"email_verified": full_user.email_verified,
|
||||
"created_at": full_user.created_at.isoformat(),
|
||||
"last_login_at": full_user.last_login_at.isoformat() if full_user.last_login_at else None,
|
||||
"login_count": full_user.login_count,
|
||||
"settings": full_user.settings
|
||||
},
|
||||
"roles": roles,
|
||||
"permissions": list(set(permissions)),
|
||||
"active_sessions": len(active_sessions),
|
||||
"api_keys": [
|
||||
{
|
||||
"id": str(key.id),
|
||||
"name": key.name,
|
||||
"created_at": key.created_at.isoformat(),
|
||||
"last_used_at": key.last_used_at.isoformat() if key.last_used_at else None,
|
||||
"expires_at": key.expires_at.isoformat() if key.expires_at else None
|
||||
}
|
||||
for key in full_user.api_keys
|
||||
if key.is_active
|
||||
]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to get current user",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to get user information", "code": "USER_INFO_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@auth_bp.route("/me", methods=["PUT"])
|
||||
@require_auth()
|
||||
@validate_request(UserUpdateSchema)
|
||||
async def update_current_user(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Update current user profile information.
|
||||
|
||||
Args:
|
||||
request: Sanic request with update data
|
||||
|
||||
Returns:
|
||||
JSONResponse: Updated user information
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
data = request.json
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Get current user
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user_result = await session.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return response.json(
|
||||
{"error": "User not found", "code": "USER_NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
# Update allowed fields
|
||||
updatable_fields = ["full_name", "bio", "avatar_url", "settings"]
|
||||
for field in updatable_fields:
|
||||
if field in data:
|
||||
if field == "full_name":
|
||||
setattr(user, field, sanitize_input(data[field]))
|
||||
elif field == "bio":
|
||||
setattr(user, field, sanitize_input(data[field], max_length=500))
|
||||
else:
|
||||
setattr(user, field, data[field])
|
||||
|
||||
# Handle email change (requires verification)
|
||||
if "email" in data and data["email"] != user.email:
|
||||
new_email = sanitize_input(data["email"])
|
||||
|
||||
# Check if email is already taken
|
||||
email_stmt = select(User).where(
|
||||
and_(User.email == new_email, User.id != user_id)
|
||||
)
|
||||
email_result = await session.execute(email_stmt)
|
||||
if email_result.scalar_one_or_none():
|
||||
return response.json(
|
||||
{"error": "Email already in use", "code": "EMAIL_IN_USE"},
|
||||
status=400
|
||||
)
|
||||
|
||||
user.email = new_email
|
||||
user.email_verified = False # Require re-verification
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
await logger.ainfo(
|
||||
"User profile updated",
|
||||
user_id=str(user_id),
|
||||
updated_fields=list(data.keys())
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"message": "Profile updated successfully",
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"bio": user.bio,
|
||||
"avatar_url": user.avatar_url,
|
||||
"updated_at": user.updated_at.isoformat()
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to update user profile",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to update profile", "code": "UPDATE_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@auth_bp.route("/api-keys", methods=["POST"])
|
||||
@rate_limit(limit=5, window=3600) # 5 API keys per hour
|
||||
@require_auth(permissions=["api.create"])
|
||||
@validate_request(ApiKeySchema)
|
||||
async def create_api_key(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Create new API key for programmatic access.
|
||||
|
||||
Args:
|
||||
request: Sanic request with API key data
|
||||
|
||||
Returns:
|
||||
JSONResponse: Created API key information
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
data = request.json
|
||||
|
||||
# Generate API key
|
||||
api_key = generate_api_key(
|
||||
user_id=user_id,
|
||||
permissions=data["permissions"],
|
||||
name=data["name"],
|
||||
expires_in=None if not data.get("expires_at") else
|
||||
int((datetime.fromisoformat(data["expires_at"]) - datetime.utcnow()).total_seconds())
|
||||
)
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
from app.core.models.user import ApiKey
|
||||
|
||||
# Create API key record
|
||||
new_api_key = ApiKey(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
name=sanitize_input(data["name"]),
|
||||
key_hash=hash_password(api_key[-32:]), # Hash last 32 chars
|
||||
permissions=data["permissions"],
|
||||
expires_at=datetime.fromisoformat(data["expires_at"]) if data.get("expires_at") else None
|
||||
)
|
||||
|
||||
session.add(new_api_key)
|
||||
await session.commit()
|
||||
await session.refresh(new_api_key)
|
||||
|
||||
await logger.ainfo(
|
||||
"API key created",
|
||||
user_id=str(user_id),
|
||||
api_key_id=str(new_api_key.id),
|
||||
name=data["name"],
|
||||
permissions=data["permissions"]
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"message": "API key created successfully",
|
||||
"api_key": {
|
||||
"id": str(new_api_key.id),
|
||||
"name": new_api_key.name,
|
||||
"key": api_key, # Only returned once
|
||||
"permissions": new_api_key.permissions,
|
||||
"created_at": new_api_key.created_at.isoformat(),
|
||||
"expires_at": new_api_key.expires_at.isoformat() if new_api_key.expires_at else None
|
||||
},
|
||||
"warning": "Save this API key securely. It will not be shown again."
|
||||
}, status=201)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to create API key",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to create API key", "code": "API_KEY_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@auth_bp.route("/sessions", methods=["GET"])
|
||||
@require_auth()
|
||||
async def get_user_sessions(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Get all active user sessions.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: List of active sessions
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
sessions_stmt = select(UserSession).where(
|
||||
and_(
|
||||
UserSession.user_id == user_id,
|
||||
UserSession.is_active == True,
|
||||
UserSession.expires_at > datetime.utcnow()
|
||||
)
|
||||
).order_by(UserSession.created_at.desc())
|
||||
|
||||
sessions_result = await session.execute(sessions_stmt)
|
||||
sessions = sessions_result.scalars().all()
|
||||
|
||||
sessions_data = []
|
||||
for sess in sessions:
|
||||
sessions_data.append({
|
||||
"id": str(sess.id),
|
||||
"ip_address": sess.ip_address,
|
||||
"user_agent": sess.user_agent,
|
||||
"created_at": sess.created_at.isoformat(),
|
||||
"last_used_at": sess.last_used_at.isoformat() if sess.last_used_at else None,
|
||||
"expires_at": sess.expires_at.isoformat(),
|
||||
"remember_me": sess.remember_me,
|
||||
"is_current": str(sess.id) == request.headers.get("X-Session-ID")
|
||||
})
|
||||
|
||||
return response.json({
|
||||
"sessions": sessions_data,
|
||||
"total": len(sessions_data)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to get user sessions",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to get sessions", "code": "SESSIONS_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@auth_bp.route("/sessions/<session_id:uuid>", methods=["DELETE"])
|
||||
@require_auth()
|
||||
async def revoke_session(request: Request, session_id: UUID) -> JSONResponse:
|
||||
"""
|
||||
Revoke specific user session.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
session_id: Session UUID to revoke
|
||||
|
||||
Returns:
|
||||
JSONResponse: Revocation status
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
session_stmt = select(UserSession).where(
|
||||
and_(
|
||||
UserSession.id == session_id,
|
||||
UserSession.user_id == user_id
|
||||
)
|
||||
)
|
||||
session_result = await session.execute(session_stmt)
|
||||
user_session = session_result.scalar_one_or_none()
|
||||
|
||||
if not user_session:
|
||||
return response.json(
|
||||
{"error": "Session not found", "code": "SESSION_NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
user_session.is_active = False
|
||||
user_session.logged_out_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
await logger.ainfo(
|
||||
"Session revoked",
|
||||
user_id=str(user_id),
|
||||
session_id=str(session_id)
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"message": "Session revoked successfully",
|
||||
"session_id": str(session_id)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to revoke session",
|
||||
user_id=str(request.ctx.user.id),
|
||||
session_id=str(session_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to revoke session", "code": "REVOKE_FAILED"},
|
||||
status=500
|
||||
)
|
||||
@@ -1,634 +0,0 @@
|
||||
"""
|
||||
Blockchain operations routes for TON integration with async wallet management.
|
||||
Provides secure transaction handling, balance queries, and smart contract interactions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, Optional, Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sanic import Blueprint, Request, response
|
||||
from sanic.response import JSONResponse
|
||||
from sqlalchemy import select, update, and_
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import db_manager, get_cache_manager
|
||||
from app.core.logging import get_logger
|
||||
from app.core.models.user import User
|
||||
from app.api.middleware import require_auth, validate_request, rate_limit
|
||||
from app.core.validation import BlockchainTransactionSchema
|
||||
from app.core.background.ton_service import TONService
|
||||
|
||||
# Initialize blueprint
|
||||
blockchain_bp = Blueprint("blockchain", url_prefix="/api/v1/blockchain")
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@blockchain_bp.route("/wallet/balance", methods=["GET"])
|
||||
@rate_limit(limit=100, window=3600) # 100 balance checks per hour
|
||||
@require_auth(permissions=["blockchain.read"])
|
||||
async def get_wallet_balance(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Get user wallet balance with caching for performance.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: Wallet balance information
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
cache_manager = get_cache_manager()
|
||||
|
||||
# Try cache first
|
||||
balance_key = f"wallet_balance:{user_id}"
|
||||
cached_balance = await cache_manager.get(balance_key)
|
||||
|
||||
if cached_balance:
|
||||
return response.json({
|
||||
"balance": cached_balance,
|
||||
"cached": True,
|
||||
"updated_at": cached_balance.get("updated_at")
|
||||
})
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Get user wallet address
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user_result = await session.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.wallet_address:
|
||||
return response.json(
|
||||
{"error": "Wallet not configured", "code": "WALLET_NOT_CONFIGURED"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Get balance from TON service
|
||||
ton_service = TONService()
|
||||
balance_data = await ton_service.get_wallet_balance(user.wallet_address)
|
||||
|
||||
if balance_data.get("error"):
|
||||
return response.json(
|
||||
{"error": balance_data["error"], "code": "BALANCE_FETCH_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
# Cache balance for 5 minutes
|
||||
balance_response = {
|
||||
"address": user.wallet_address,
|
||||
"balance_nanotons": balance_data["balance"],
|
||||
"balance_tons": str(Decimal(balance_data["balance"]) / Decimal("1000000000")),
|
||||
"last_transaction_lt": balance_data.get("last_transaction_lt"),
|
||||
"updated_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
await cache_manager.set(balance_key, balance_response, ttl=300)
|
||||
|
||||
await logger.ainfo(
|
||||
"Wallet balance retrieved",
|
||||
user_id=str(user_id),
|
||||
address=user.wallet_address,
|
||||
balance=balance_data["balance"]
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"balance": balance_response,
|
||||
"cached": False
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to get wallet balance",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to get balance", "code": "BALANCE_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@blockchain_bp.route("/wallet/transactions", methods=["GET"])
|
||||
@rate_limit(limit=50, window=3600) # 50 transaction history requests per hour
|
||||
@require_auth(permissions=["blockchain.read"])
|
||||
async def get_wallet_transactions(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Get wallet transaction history with pagination.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: Transaction history
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
# Parse query parameters
|
||||
limit = min(int(request.args.get("limit", 20)), 100) # Max 100 transactions
|
||||
offset = max(int(request.args.get("offset", 0)), 0)
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Get user wallet address
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user_result = await session.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.wallet_address:
|
||||
return response.json(
|
||||
{"error": "Wallet not configured", "code": "WALLET_NOT_CONFIGURED"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Check cache for recent transactions
|
||||
cache_manager = get_cache_manager()
|
||||
cache_key = f"wallet_transactions:{user_id}:{limit}:{offset}"
|
||||
cached_transactions = await cache_manager.get(cache_key)
|
||||
|
||||
if cached_transactions:
|
||||
return response.json({
|
||||
"transactions": cached_transactions,
|
||||
"cached": True
|
||||
})
|
||||
|
||||
# Get transactions from TON service
|
||||
ton_service = TONService()
|
||||
transactions_data = await ton_service.get_wallet_transactions(
|
||||
user.wallet_address,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
if transactions_data.get("error"):
|
||||
return response.json(
|
||||
{"error": transactions_data["error"], "code": "TRANSACTIONS_FETCH_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
# Process and format transactions
|
||||
formatted_transactions = []
|
||||
for tx in transactions_data.get("transactions", []):
|
||||
formatted_tx = {
|
||||
"hash": tx.get("hash"),
|
||||
"lt": tx.get("lt"),
|
||||
"timestamp": tx.get("utime"),
|
||||
"value": tx.get("value", "0"),
|
||||
"value_tons": str(Decimal(tx.get("value", "0")) / Decimal("1000000000")),
|
||||
"fee": tx.get("fee", "0"),
|
||||
"source": tx.get("in_msg", {}).get("source"),
|
||||
"destination": tx.get("out_msgs", [{}])[0].get("destination"),
|
||||
"message": tx.get("in_msg", {}).get("message", ""),
|
||||
"type": "incoming" if tx.get("in_msg") else "outgoing",
|
||||
"status": "success" if tx.get("success") else "failed"
|
||||
}
|
||||
formatted_transactions.append(formatted_tx)
|
||||
|
||||
# Cache for 2 minutes
|
||||
await cache_manager.set(cache_key, formatted_transactions, ttl=120)
|
||||
|
||||
return response.json({
|
||||
"transactions": formatted_transactions,
|
||||
"total": len(formatted_transactions),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"cached": False
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to get wallet transactions",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to get transactions", "code": "TRANSACTIONS_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@blockchain_bp.route("/transaction/send", methods=["POST"])
|
||||
@rate_limit(limit=10, window=3600) # 10 transactions per hour
|
||||
@require_auth(permissions=["blockchain.write"])
|
||||
@validate_request(BlockchainTransactionSchema)
|
||||
async def send_transaction(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Send TON transaction with comprehensive validation and monitoring.
|
||||
|
||||
Args:
|
||||
request: Sanic request with transaction data
|
||||
|
||||
Returns:
|
||||
JSONResponse: Transaction submission result
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
data = request.json
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Get user with wallet
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user_result = await session.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user or not user.wallet_address or not user.wallet_private_key:
|
||||
return response.json(
|
||||
{"error": "Wallet not properly configured", "code": "WALLET_INCOMPLETE"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Validate transaction limits
|
||||
amount_nanotons = data.get("amount", 0)
|
||||
max_transaction = settings.MAX_TRANSACTION_AMOUNT * 1000000000 # Convert to nanotons
|
||||
|
||||
if amount_nanotons > max_transaction:
|
||||
return response.json(
|
||||
{"error": f"Amount exceeds maximum allowed ({settings.MAX_TRANSACTION_AMOUNT} TON)",
|
||||
"code": "AMOUNT_EXCEEDED"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Check daily transaction limit
|
||||
cache_manager = get_cache_manager()
|
||||
daily_limit_key = f"daily_transactions:{user_id}:{datetime.utcnow().date()}"
|
||||
daily_amount = await cache_manager.get(daily_limit_key, default=0)
|
||||
|
||||
if daily_amount + amount_nanotons > settings.DAILY_TRANSACTION_LIMIT * 1000000000:
|
||||
return response.json(
|
||||
{"error": "Daily transaction limit exceeded", "code": "DAILY_LIMIT_EXCEEDED"},
|
||||
status=429
|
||||
)
|
||||
|
||||
# Prepare transaction
|
||||
transaction_data = {
|
||||
"transaction_type": data["transaction_type"],
|
||||
"recipient_address": data.get("recipient_address"),
|
||||
"amount": amount_nanotons,
|
||||
"message": data.get("message", ""),
|
||||
"sender_address": user.wallet_address
|
||||
}
|
||||
|
||||
# Send transaction via TON service
|
||||
ton_service = TONService()
|
||||
tx_result = await ton_service.send_transaction(
|
||||
private_key=user.wallet_private_key,
|
||||
**transaction_data
|
||||
)
|
||||
|
||||
if tx_result.get("error"):
|
||||
await logger.awarning(
|
||||
"Transaction failed",
|
||||
user_id=str(user_id),
|
||||
error=tx_result["error"],
|
||||
**transaction_data
|
||||
)
|
||||
return response.json(
|
||||
{"error": tx_result["error"], "code": "TRANSACTION_FAILED"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Update daily limit counter
|
||||
await cache_manager.increment(daily_limit_key, amount_nanotons, ttl=86400)
|
||||
|
||||
# Store transaction record
|
||||
from app.core.models.blockchain import BlockchainTransaction
|
||||
async with db_manager.get_session() as session:
|
||||
tx_record = BlockchainTransaction(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
transaction_hash=tx_result["hash"],
|
||||
transaction_type=data["transaction_type"],
|
||||
amount=amount_nanotons,
|
||||
recipient_address=data.get("recipient_address"),
|
||||
sender_address=user.wallet_address,
|
||||
message=data.get("message", ""),
|
||||
status="pending",
|
||||
network_fee=tx_result.get("fee", 0),
|
||||
block_hash=tx_result.get("block_hash"),
|
||||
logical_time=tx_result.get("lt")
|
||||
)
|
||||
session.add(tx_record)
|
||||
await session.commit()
|
||||
|
||||
# Clear balance cache
|
||||
balance_key = f"wallet_balance:{user_id}"
|
||||
await cache_manager.delete(balance_key)
|
||||
|
||||
await logger.ainfo(
|
||||
"Transaction sent successfully",
|
||||
user_id=str(user_id),
|
||||
transaction_hash=tx_result["hash"],
|
||||
amount=amount_nanotons,
|
||||
recipient=data.get("recipient_address")
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"message": "Transaction sent successfully",
|
||||
"transaction": {
|
||||
"hash": tx_result["hash"],
|
||||
"amount": amount_nanotons,
|
||||
"amount_tons": str(Decimal(amount_nanotons) / Decimal("1000000000")),
|
||||
"recipient": data.get("recipient_address"),
|
||||
"fee": tx_result.get("fee", 0),
|
||||
"status": "pending",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
}, status=201)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to send transaction",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to send transaction", "code": "SEND_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@blockchain_bp.route("/transaction/<tx_hash>/status", methods=["GET"])
|
||||
@rate_limit(limit=100, window=3600) # 100 status checks per hour
|
||||
@require_auth(permissions=["blockchain.read"])
|
||||
async def get_transaction_status(request: Request, tx_hash: str) -> JSONResponse:
|
||||
"""
|
||||
Get transaction status and confirmation details.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
tx_hash: Transaction hash to check
|
||||
|
||||
Returns:
|
||||
JSONResponse: Transaction status information
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
# Check cache first
|
||||
cache_manager = get_cache_manager()
|
||||
status_key = f"tx_status:{tx_hash}"
|
||||
cached_status = await cache_manager.get(status_key)
|
||||
|
||||
if cached_status and cached_status.get("status") in ["confirmed", "failed"]:
|
||||
# Cache confirmed/failed transactions longer
|
||||
return response.json(cached_status)
|
||||
|
||||
# Get transaction from database
|
||||
async with db_manager.get_session() as session:
|
||||
from app.core.models.blockchain import BlockchainTransaction
|
||||
|
||||
tx_stmt = select(BlockchainTransaction).where(
|
||||
and_(
|
||||
BlockchainTransaction.transaction_hash == tx_hash,
|
||||
BlockchainTransaction.user_id == user_id
|
||||
)
|
||||
)
|
||||
tx_result = await session.execute(tx_stmt)
|
||||
tx_record = tx_result.scalar_one_or_none()
|
||||
|
||||
if not tx_record:
|
||||
return response.json(
|
||||
{"error": "Transaction not found", "code": "TRANSACTION_NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
# Get current status from blockchain
|
||||
ton_service = TONService()
|
||||
status_data = await ton_service.get_transaction_status(tx_hash)
|
||||
|
||||
if status_data.get("error"):
|
||||
# Return database status if blockchain query fails
|
||||
tx_status = {
|
||||
"hash": tx_record.transaction_hash,
|
||||
"status": tx_record.status,
|
||||
"confirmations": 0,
|
||||
"amount": tx_record.amount,
|
||||
"created_at": tx_record.created_at.isoformat(),
|
||||
"blockchain_error": status_data["error"]
|
||||
}
|
||||
else:
|
||||
# Update status based on blockchain data
|
||||
new_status = "confirmed" if status_data.get("confirmed") else "pending"
|
||||
if status_data.get("failed"):
|
||||
new_status = "failed"
|
||||
|
||||
tx_status = {
|
||||
"hash": tx_record.transaction_hash,
|
||||
"status": new_status,
|
||||
"confirmations": status_data.get("confirmations", 0),
|
||||
"block_hash": status_data.get("block_hash"),
|
||||
"block_time": status_data.get("block_time"),
|
||||
"amount": tx_record.amount,
|
||||
"fee": status_data.get("fee", tx_record.network_fee),
|
||||
"created_at": tx_record.created_at.isoformat(),
|
||||
"confirmed_at": status_data.get("confirmed_at")
|
||||
}
|
||||
|
||||
# Update database record if status changed
|
||||
if tx_record.status != new_status:
|
||||
async with db_manager.get_session() as session:
|
||||
update_stmt = (
|
||||
update(BlockchainTransaction)
|
||||
.where(BlockchainTransaction.id == tx_record.id)
|
||||
.values(
|
||||
status=new_status,
|
||||
confirmations=status_data.get("confirmations", 0),
|
||||
confirmed_at=datetime.fromisoformat(status_data["confirmed_at"])
|
||||
if status_data.get("confirmed_at") else None
|
||||
)
|
||||
)
|
||||
await session.execute(update_stmt)
|
||||
await session.commit()
|
||||
|
||||
# Cache status (longer for final states)
|
||||
cache_ttl = 300 if tx_status["status"] == "pending" else 3600 # 5 min vs 1 hour
|
||||
await cache_manager.set(status_key, tx_status, ttl=cache_ttl)
|
||||
|
||||
return response.json(tx_status)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to get transaction status",
|
||||
user_id=str(request.ctx.user.id),
|
||||
tx_hash=tx_hash,
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to get transaction status", "code": "STATUS_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@blockchain_bp.route("/wallet/create", methods=["POST"])
|
||||
@rate_limit(limit=1, window=86400) # 1 wallet creation per day
|
||||
@require_auth(permissions=["blockchain.wallet.create"])
|
||||
async def create_wallet(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Create new TON wallet for user (one per user).
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: Wallet creation result
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Check if user already has a wallet
|
||||
user_stmt = select(User).where(User.id == user_id)
|
||||
user_result = await session.execute(user_stmt)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return response.json(
|
||||
{"error": "User not found", "code": "USER_NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
if user.wallet_address:
|
||||
return response.json(
|
||||
{"error": "Wallet already exists", "code": "WALLET_EXISTS"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Create wallet via TON service
|
||||
ton_service = TONService()
|
||||
wallet_data = await ton_service.create_wallet()
|
||||
|
||||
if wallet_data.get("error"):
|
||||
return response.json(
|
||||
{"error": wallet_data["error"], "code": "WALLET_CREATION_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
# Store wallet information (encrypt private key)
|
||||
from app.core.security import encrypt_data
|
||||
encrypted_private_key = encrypt_data(
|
||||
wallet_data["private_key"],
|
||||
context=f"wallet:{user_id}"
|
||||
)
|
||||
|
||||
user.wallet_address = wallet_data["address"]
|
||||
user.wallet_private_key = encrypted_private_key
|
||||
user.wallet_created_at = datetime.utcnow()
|
||||
|
||||
await session.commit()
|
||||
|
||||
await logger.ainfo(
|
||||
"Wallet created successfully",
|
||||
user_id=str(user_id),
|
||||
wallet_address=wallet_data["address"]
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"message": "Wallet created successfully",
|
||||
"wallet": {
|
||||
"address": wallet_data["address"],
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"balance": "0",
|
||||
"network": "TON"
|
||||
},
|
||||
"security_note": "Private key is encrypted and stored securely. Keep your account secure."
|
||||
}, status=201)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to create wallet",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to create wallet", "code": "WALLET_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@blockchain_bp.route("/stats", methods=["GET"])
|
||||
@rate_limit(limit=50, window=3600) # 50 stats requests per hour
|
||||
@require_auth(permissions=["blockchain.read"])
|
||||
async def get_blockchain_stats(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Get user blockchain activity statistics.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: Blockchain activity statistics
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
from sqlalchemy import func
|
||||
from app.core.models.blockchain import BlockchainTransaction
|
||||
|
||||
# Get transaction statistics
|
||||
stats_stmt = select(
|
||||
func.count(BlockchainTransaction.id).label('total_transactions'),
|
||||
func.sum(BlockchainTransaction.amount).label('total_amount'),
|
||||
func.sum(BlockchainTransaction.network_fee).label('total_fees')
|
||||
).where(BlockchainTransaction.user_id == user_id)
|
||||
|
||||
stats_result = await session.execute(stats_stmt)
|
||||
stats = stats_result.first()
|
||||
|
||||
# Get transactions by type
|
||||
type_stats_stmt = select(
|
||||
BlockchainTransaction.transaction_type,
|
||||
func.count(BlockchainTransaction.id).label('count'),
|
||||
func.sum(BlockchainTransaction.amount).label('amount')
|
||||
).where(
|
||||
BlockchainTransaction.user_id == user_id
|
||||
).group_by(BlockchainTransaction.transaction_type)
|
||||
|
||||
type_result = await session.execute(type_stats_stmt)
|
||||
type_stats = {
|
||||
row.transaction_type: {
|
||||
'count': row.count,
|
||||
'total_amount': row.amount or 0
|
||||
}
|
||||
for row in type_result
|
||||
}
|
||||
|
||||
# Get recent activity (last 30 days)
|
||||
recent_date = datetime.utcnow() - timedelta(days=30)
|
||||
recent_stmt = select(
|
||||
func.count(BlockchainTransaction.id).label('recent_count'),
|
||||
func.sum(BlockchainTransaction.amount).label('recent_amount')
|
||||
).where(
|
||||
and_(
|
||||
BlockchainTransaction.user_id == user_id,
|
||||
BlockchainTransaction.created_at >= recent_date
|
||||
)
|
||||
)
|
||||
|
||||
recent_result = await session.execute(recent_stmt)
|
||||
recent_stats = recent_result.first()
|
||||
|
||||
blockchain_stats = {
|
||||
"total_transactions": stats.total_transactions or 0,
|
||||
"total_amount_nanotons": stats.total_amount or 0,
|
||||
"total_amount_tons": str(Decimal(stats.total_amount or 0) / Decimal("1000000000")),
|
||||
"total_fees_nanotons": stats.total_fees or 0,
|
||||
"total_fees_tons": str(Decimal(stats.total_fees or 0) / Decimal("1000000000")),
|
||||
"by_type": type_stats,
|
||||
"recent_activity": {
|
||||
"transactions_30d": recent_stats.recent_count or 0,
|
||||
"amount_30d_nanotons": recent_stats.recent_amount or 0,
|
||||
"amount_30d_tons": str(Decimal(recent_stats.recent_amount or 0) / Decimal("1000000000"))
|
||||
},
|
||||
"generated_at": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
return response.json(blockchain_stats)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to get blockchain stats",
|
||||
user_id=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to get blockchain statistics", "code": "STATS_FAILED"},
|
||||
status=500
|
||||
)
|
||||
@@ -1,280 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
from sanic import response
|
||||
from aiogram import Bot, types
|
||||
from sqlalchemy import and_
|
||||
from app.core.logger import make_log
|
||||
from app.core.models._config import ServiceConfig
|
||||
from app.core.models.node_storage import StoredContent
|
||||
from app.core.models.keys import KnownKey
|
||||
from app.core.models import StarsInvoice
|
||||
from app.core.models.content.user_content import UserContent
|
||||
from app.core._config import CLIENT_TELEGRAM_API_KEY, PROJECT_HOST
|
||||
import json
|
||||
import uuid
|
||||
|
||||
|
||||
async def s_api_v1_content_list(request):
|
||||
offset = int(request.args.get('offset', 0))
|
||||
limit = int(request.args.get('limit', 100))
|
||||
assert 0 <= offset, "Invalid offset"
|
||||
assert 0 < limit <= 1000, "Invalid limit"
|
||||
|
||||
store = request.args.get('store', 'local')
|
||||
assert store in ('local', 'onchain'), "Invalid store"
|
||||
|
||||
content_list = request.ctx.db_session.query(StoredContent).filter(
|
||||
StoredContent.type.like(store + '%'),
|
||||
StoredContent.disabled == False
|
||||
).order_by(StoredContent.created.desc()).offset(offset).limit(limit)
|
||||
make_log("Content", f"Listed {content_list.count()} contents", level='info')
|
||||
result = {}
|
||||
for content in content_list.all():
|
||||
content_json = content.json_format()
|
||||
result[content_json["cid"]] = content_json
|
||||
|
||||
return response.json(result)
|
||||
|
||||
|
||||
async def s_api_v1_content_view(request, content_address: str):
|
||||
# content_address can be CID or TON address
|
||||
|
||||
license_exist = request.ctx.db_session.query(UserContent).filter_by(
|
||||
onchain_address=content_address,
|
||||
).first()
|
||||
if license_exist:
|
||||
content_address = license_exist.content.cid.serialize_v2()
|
||||
|
||||
r_content = StoredContent.from_cid(request.ctx.db_session, content_address)
|
||||
content = r_content.open_content(request.ctx.db_session)
|
||||
|
||||
opts = {
|
||||
'content_type': content['content_type'], # возможно с ошибками, нужно переделать на ffprobe
|
||||
'content_address': content['encrypted_content'].meta.get('item_address', '')
|
||||
}
|
||||
if content['encrypted_content'].key_id:
|
||||
known_key = request.ctx.db_session.query(KnownKey).filter(
|
||||
KnownKey.id == content['encrypted_content'].key_id
|
||||
).first()
|
||||
if known_key:
|
||||
opts['key_hash'] = known_key.seed_hash # нахер не нужно на данный момент
|
||||
|
||||
# чисто болванки, заполнение дальше
|
||||
opts['have_licenses'] = []
|
||||
opts['invoice'] = None
|
||||
|
||||
have_access = False
|
||||
if request.ctx.user:
|
||||
user_wallet_address = request.ctx.user.wallet_address(request.ctx.db_session)
|
||||
have_access = (
|
||||
(content['encrypted_content'].owner_address == user_wallet_address)
|
||||
or bool(request.ctx.db_session.query(UserContent).filter_by(owner_address=user_wallet_address, status='active',
|
||||
content_id=content['encrypted_content'].id).first()) \
|
||||
or bool(request.ctx.db_session.query(StarsInvoice).filter(
|
||||
and_(
|
||||
StarsInvoice.user_id == request.ctx.user.id,
|
||||
StarsInvoice.content_hash == content['encrypted_content'].hash,
|
||||
StarsInvoice.paid == True
|
||||
)
|
||||
).first())
|
||||
)
|
||||
|
||||
if not have_access:
|
||||
current_star_rate = ServiceConfig(request.ctx.db_session).get('live_tonPerStar', [0, 0])[0]
|
||||
if current_star_rate < 0:
|
||||
current_star_rate = 0.00000001
|
||||
|
||||
stars_cost = int(int(content['encrypted_content'].meta['license']['resale']['price']) / 1e9 / current_star_rate * 1.2)
|
||||
if request.ctx.user.telegram_id in [5587262915, 6861699286]:
|
||||
stars_cost = 2
|
||||
|
||||
invoice_id = f"access_{uuid.uuid4().hex}"
|
||||
exist_invoice = request.ctx.db_session.query(StarsInvoice).filter(
|
||||
and_(
|
||||
StarsInvoice.user_id == request.ctx.user.id,
|
||||
StarsInvoice.created > datetime.now() - timedelta(minutes=25),
|
||||
StarsInvoice.amount == stars_cost,
|
||||
StarsInvoice.content_hash == content['encrypted_content'].hash,
|
||||
)
|
||||
).first()
|
||||
if exist_invoice:
|
||||
invoice_url = exist_invoice.invoice_url
|
||||
else:
|
||||
invoice_url = None
|
||||
try:
|
||||
invoice_url = await Bot(token=CLIENT_TELEGRAM_API_KEY).create_invoice_link(
|
||||
'Неограниченный доступ к контенту',
|
||||
'Неограниченный доступ к контенту',
|
||||
invoice_id, "XTR",
|
||||
[
|
||||
types.LabeledPrice(label='Lifetime access', amount=stars_cost),
|
||||
], provider_token = ''
|
||||
)
|
||||
request.ctx.db_session.add(
|
||||
StarsInvoice(
|
||||
external_id=invoice_id,
|
||||
type='access',
|
||||
amount=stars_cost,
|
||||
user_id=request.ctx.user.id,
|
||||
content_hash=content['encrypted_content'].hash,
|
||||
invoice_url=invoice_url
|
||||
)
|
||||
)
|
||||
request.ctx.db_session.commit()
|
||||
except BaseException as e:
|
||||
make_log("Content", f"Can't create invoice link: {e}", level='warning')
|
||||
|
||||
if invoice_url:
|
||||
opts['invoice'] = {
|
||||
'url': invoice_url,
|
||||
'amount': stars_cost,
|
||||
}
|
||||
|
||||
display_options = {
|
||||
'content_url': None,
|
||||
}
|
||||
|
||||
if have_access:
|
||||
opts['have_licenses'].append('listen')
|
||||
|
||||
converted_content = content['encrypted_content'].meta.get('converted_content')
|
||||
if converted_content:
|
||||
user_content_option = 'low_preview'
|
||||
if have_access:
|
||||
user_content_option = 'low' # TODO: подключать high если человек внезапно меломан
|
||||
|
||||
converted_content = request.ctx.db_session.query(StoredContent).filter(
|
||||
StoredContent.hash == converted_content[user_content_option]
|
||||
).first()
|
||||
if converted_content:
|
||||
display_options['content_url'] = converted_content.web_url
|
||||
opts['content_ext'] = converted_content.filename.split('.')[-1]
|
||||
|
||||
content_meta = content['encrypted_content'].json_format()
|
||||
content_metadata = StoredContent.from_cid(request.ctx.db_session, content_meta.get('metadata_cid') or None)
|
||||
with open(content_metadata.filepath, 'r') as f:
|
||||
content_metadata_json = json.loads(f.read())
|
||||
|
||||
display_options['metadata'] = content_metadata_json
|
||||
opts['downloadable'] = content_metadata_json.get('downloadable', False)
|
||||
if opts['downloadable']:
|
||||
if not ('listen' in opts['have_licenses']):
|
||||
opts['downloadable'] = False
|
||||
|
||||
return response.json({
|
||||
**opts,
|
||||
'encrypted': content['encrypted_content'].json_format(),
|
||||
'display_options': display_options,
|
||||
})
|
||||
|
||||
|
||||
async def s_api_v1_content_friendly_list(request):
|
||||
# return html table with content list. bootstrap is used
|
||||
|
||||
result = """
|
||||
<html>
|
||||
<head>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CID</th>
|
||||
<th>Title</th>
|
||||
<th>Onchain</th>
|
||||
<th>Preview link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
"""
|
||||
for content in request.ctx.db_session.query(StoredContent).filter(
|
||||
StoredContent.type == 'onchain/content'
|
||||
).all():
|
||||
if not content.meta.get('metadata_cid'):
|
||||
make_log("Content", f"Content {content.cid.serialize_v2()} has no metadata", level='warning')
|
||||
continue
|
||||
|
||||
metadata_content = StoredContent.from_cid(request.ctx.db_session, content.meta.get('metadata_cid'))
|
||||
with open(metadata_content.filepath, 'r') as f:
|
||||
metadata = json.loads(f.read())
|
||||
|
||||
preview_link = None
|
||||
if content.meta.get('converted_content'):
|
||||
preview_link = f"{PROJECT_HOST}/api/v1.5/storage/{content.meta['converted_content']['low_preview']}"
|
||||
|
||||
result += f"""
|
||||
<tr>
|
||||
<td>{content.cid.serialize_v2()}</td>
|
||||
<td>{metadata.get('name', "")}</td>
|
||||
<td>{content.meta.get('item_address')}</td>
|
||||
<td>""" + (f'<a href="{preview_link}">Preview</a>' if preview_link else "not ready") + """</td>
|
||||
</tr>
|
||||
"""
|
||||
result += """
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return response.html(result)
|
||||
|
||||
async def s_api_v1_5_content_list(request):
|
||||
# Validate offset and limit parameters
|
||||
offset = int(request.args.get('offset', 0))
|
||||
limit = int(request.args.get('limit', 100))
|
||||
if offset < 0:
|
||||
return response.json({'error': 'Invalid offset'}, status=400)
|
||||
|
||||
if limit <= 0 or limit > 1000:
|
||||
return response.json({'error': 'Invalid limit'}, status=400)
|
||||
|
||||
# Query onchain contents which are not disabled
|
||||
contents = request.ctx.db_session.query(StoredContent).filter(
|
||||
StoredContent.type == 'onchain/content',
|
||||
StoredContent.disabled == False
|
||||
).order_by(StoredContent.created.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for content in contents:
|
||||
# Retrieve metadata content using metadata_cid from content.meta
|
||||
metadata_cid = content.meta.get('metadata_cid')
|
||||
if not metadata_cid:
|
||||
continue # Skip if no metadata_cid is found
|
||||
|
||||
metadata_content = StoredContent.from_cid(request.ctx.db_session, metadata_cid)
|
||||
try:
|
||||
with open(metadata_content.filepath, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
except Exception as e:
|
||||
metadata = {}
|
||||
|
||||
media_type = 'audio'
|
||||
|
||||
# Get title from metadata (key 'name')
|
||||
title = metadata.get('name', '')
|
||||
|
||||
# Build preview link if converted_content exists and contains 'low_preview'
|
||||
preview_link = None
|
||||
converted_content = content.meta.get('converted_content')
|
||||
if converted_content:
|
||||
converted_content = request.ctx.db_session.query(StoredContent).filter(
|
||||
StoredContent.hash == converted_content['low_preview']
|
||||
).first()
|
||||
preview_link = converted_content.web_url
|
||||
if converted_content.filename.split('.')[-1] in ('mp4', 'mov'):
|
||||
media_type = 'video'
|
||||
else:
|
||||
preview_link = None
|
||||
|
||||
# Get onchain address from content.meta
|
||||
onchain_address = content.meta.get('item_address', '')
|
||||
|
||||
result.append({
|
||||
'cid': content.cid.serialize_v2(),
|
||||
'onchain_address': onchain_address,
|
||||
'type': media_type,
|
||||
'title': title,
|
||||
'preview_link': preview_link,
|
||||
'created_at': content.created.isoformat() # ISO 8601 format for datetime
|
||||
})
|
||||
|
||||
return response.json(result)
|
||||
@@ -1,592 +0,0 @@
|
||||
"""
|
||||
Enhanced content management routes with async operations and comprehensive validation.
|
||||
Provides secure upload, download, metadata management with Redis caching.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sanic import Blueprint, Request, response
|
||||
from sanic.response import JSONResponse, ResponseStream
|
||||
from sqlalchemy import select, update, delete, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import db_manager, get_cache_manager
|
||||
from app.core.logging import get_logger
|
||||
from app.core.models.content_models import StoredContent as Content, UserContent as ContentMetadata, EncryptionKey as License
|
||||
from app.core.models.content.user_content import UserContent as ContentAccess
|
||||
from app.core.models.user import User
|
||||
from app.api.middleware import require_auth, validate_request, rate_limit
|
||||
from app.core.validation import ContentSchema, ContentUpdateSchema, ContentSearchSchema
|
||||
from app.core.storage import StorageManager
|
||||
from app.core.security import encrypt_data, decrypt_data, generate_access_token
|
||||
|
||||
# Initialize blueprint
|
||||
content_bp = Blueprint("content", url_prefix="/api/v1/content")
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@content_bp.route("/", methods=["POST"])
|
||||
@rate_limit(limit=50, window=3600) # 50 uploads per hour
|
||||
@require_auth(permissions=["content.create"])
|
||||
@validate_request(ContentSchema)
|
||||
async def create_content(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Create new content with metadata and security validation.
|
||||
|
||||
Args:
|
||||
request: Sanic request with validated content data
|
||||
|
||||
Returns:
|
||||
JSONResponse: Created content information with upload URLs
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Check user upload quota
|
||||
quota_key = f"user:{user_id}:upload_quota"
|
||||
cache_manager = get_cache_manager()
|
||||
|
||||
current_quota = await cache_manager.get(quota_key, default=0)
|
||||
if current_quota >= settings.MAX_UPLOADS_PER_DAY:
|
||||
return response.json(
|
||||
{"error": "Upload quota exceeded", "code": "QUOTA_EXCEEDED"},
|
||||
status=429
|
||||
)
|
||||
|
||||
# Create content record
|
||||
content = Content(
|
||||
id=uuid4(),
|
||||
user_id=user_id,
|
||||
title=data["title"],
|
||||
description=data.get("description"),
|
||||
content_type=data["content_type"],
|
||||
file_size=data.get("file_size", 0),
|
||||
status="pending",
|
||||
visibility=data.get("visibility", "private"),
|
||||
tags=data.get("tags", []),
|
||||
license_id=data.get("license_id")
|
||||
)
|
||||
|
||||
session.add(content)
|
||||
|
||||
# Create metadata if provided
|
||||
if data.get("metadata"):
|
||||
metadata = ContentMetadata(
|
||||
content_id=content.id,
|
||||
metadata_type="custom",
|
||||
data=data["metadata"]
|
||||
)
|
||||
session.add(metadata)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(content)
|
||||
|
||||
# Update quota counter
|
||||
await cache_manager.increment(quota_key, ttl=86400) # 24 hours
|
||||
|
||||
# Generate upload URLs for chunked upload
|
||||
storage_manager = StorageManager()
|
||||
upload_info = await storage_manager.create_upload_session(
|
||||
content.id, data.get("file_size", 0)
|
||||
)
|
||||
|
||||
# Cache content for quick access
|
||||
content_cache_key = f"content:{content.id}"
|
||||
await cache_manager.set(
|
||||
content_cache_key,
|
||||
{
|
||||
"id": str(content.id),
|
||||
"title": content.title,
|
||||
"status": content.status,
|
||||
"user_id": str(content.user_id)
|
||||
},
|
||||
ttl=3600
|
||||
)
|
||||
|
||||
await logger.ainfo(
|
||||
"Content created successfully",
|
||||
content_id=str(content.id),
|
||||
user_id=str(user_id),
|
||||
title=content.title
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"content_id": str(content.id),
|
||||
"upload_session": upload_info,
|
||||
"status": content.status,
|
||||
"created_at": content.created_at.isoformat()
|
||||
}, status=201)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to create content",
|
||||
error=str(e),
|
||||
user_id=str(user_id)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to create content", "code": "CREATION_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@content_bp.route("/<content_id:uuid>", methods=["GET"])
|
||||
@rate_limit(limit=200, window=3600) # 200 requests per hour
|
||||
@require_auth(permissions=["content.read"])
|
||||
async def get_content(request: Request, content_id: UUID) -> JSONResponse:
|
||||
"""
|
||||
Retrieve content information with access control and caching.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
content_id: UUID of the content to retrieve
|
||||
|
||||
Returns:
|
||||
JSONResponse: Content information or error
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
cache_manager = get_cache_manager()
|
||||
|
||||
# Try cache first
|
||||
cache_key = f"content:{content_id}:full"
|
||||
cached_content = await cache_manager.get(cache_key)
|
||||
|
||||
if cached_content:
|
||||
# Check access permissions from cache
|
||||
if await _check_content_access(content_id, user_id, "read"):
|
||||
return response.json(cached_content)
|
||||
else:
|
||||
return response.json(
|
||||
{"error": "Access denied", "code": "ACCESS_DENIED"},
|
||||
status=403
|
||||
)
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Load content with relationships
|
||||
stmt = (
|
||||
select(Content)
|
||||
.options(
|
||||
selectinload(Content.metadata),
|
||||
selectinload(Content.access_controls),
|
||||
selectinload(Content.license)
|
||||
)
|
||||
.where(Content.id == content_id)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
content = result.scalar_one_or_none()
|
||||
|
||||
if not content:
|
||||
return response.json(
|
||||
{"error": "Content not found", "code": "NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
# Check access permissions
|
||||
if not await _check_content_access_db(session, content, user_id, "read"):
|
||||
return response.json(
|
||||
{"error": "Access denied", "code": "ACCESS_DENIED"},
|
||||
status=403
|
||||
)
|
||||
|
||||
# Prepare response data
|
||||
content_data = {
|
||||
"id": str(content.id),
|
||||
"title": content.title,
|
||||
"description": content.description,
|
||||
"content_type": content.content_type,
|
||||
"file_size": content.file_size,
|
||||
"status": content.status,
|
||||
"visibility": content.visibility,
|
||||
"tags": content.tags,
|
||||
"created_at": content.created_at.isoformat(),
|
||||
"updated_at": content.updated_at.isoformat(),
|
||||
"metadata": [
|
||||
{
|
||||
"type": m.metadata_type,
|
||||
"data": m.data
|
||||
} for m in content.metadata
|
||||
],
|
||||
"license": {
|
||||
"name": content.license.name,
|
||||
"description": content.license.description
|
||||
} if content.license else None
|
||||
}
|
||||
|
||||
# Cache the result
|
||||
await cache_manager.set(cache_key, content_data, ttl=1800) # 30 minutes
|
||||
|
||||
# Update access statistics
|
||||
await _update_access_stats(content_id, user_id, "view")
|
||||
|
||||
return response.json(content_data)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to retrieve content",
|
||||
content_id=str(content_id),
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to retrieve content", "code": "RETRIEVAL_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@content_bp.route("/<content_id:uuid>", methods=["PUT"])
|
||||
@rate_limit(limit=100, window=3600) # 100 updates per hour
|
||||
@require_auth(permissions=["content.update"])
|
||||
@validate_request(ContentUpdateSchema)
|
||||
async def update_content(request: Request, content_id: UUID) -> JSONResponse:
|
||||
"""
|
||||
Update content metadata and settings with validation.
|
||||
|
||||
Args:
|
||||
request: Sanic request with update data
|
||||
content_id: UUID of content to update
|
||||
|
||||
Returns:
|
||||
JSONResponse: Updated content information
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Load existing content
|
||||
stmt = select(Content).where(Content.id == content_id)
|
||||
result = await session.execute(stmt)
|
||||
content = result.scalar_one_or_none()
|
||||
|
||||
if not content:
|
||||
return response.json(
|
||||
{"error": "Content not found", "code": "NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
# Check update permissions
|
||||
if not await _check_content_access_db(session, content, user_id, "update"):
|
||||
return response.json(
|
||||
{"error": "Access denied", "code": "ACCESS_DENIED"},
|
||||
status=403
|
||||
)
|
||||
|
||||
# Update fields
|
||||
for field, value in data.items():
|
||||
if hasattr(content, field) and field not in ["id", "user_id", "created_at"]:
|
||||
setattr(content, field, value)
|
||||
|
||||
content.updated_at = datetime.utcnow()
|
||||
await session.commit()
|
||||
|
||||
# Invalidate caches
|
||||
cache_manager = get_cache_manager()
|
||||
await cache_manager.delete(f"content:{content_id}")
|
||||
await cache_manager.delete(f"content:{content_id}:full")
|
||||
|
||||
await logger.ainfo(
|
||||
"Content updated successfully",
|
||||
content_id=str(content_id),
|
||||
user_id=str(user_id),
|
||||
updated_fields=list(data.keys())
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"content_id": str(content_id),
|
||||
"status": "updated",
|
||||
"updated_at": content.updated_at.isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to update content",
|
||||
content_id=str(content_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to update content", "code": "UPDATE_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@content_bp.route("/search", methods=["POST"])
|
||||
@rate_limit(limit=100, window=3600) # 100 searches per hour
|
||||
@require_auth(permissions=["content.read"])
|
||||
@validate_request(ContentSearchSchema)
|
||||
async def search_content(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Search content with filters, pagination and caching.
|
||||
|
||||
Args:
|
||||
request: Sanic request with search parameters
|
||||
|
||||
Returns:
|
||||
JSONResponse: Search results with pagination
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
# Build cache key from search parameters
|
||||
search_key = f"search:{hash(str(sorted(data.items())))}:{user_id}"
|
||||
cache_manager = get_cache_manager()
|
||||
|
||||
# Try cache first
|
||||
cached_results = await cache_manager.get(search_key)
|
||||
if cached_results:
|
||||
return response.json(cached_results)
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Build base query
|
||||
stmt = select(Content).where(
|
||||
or_(
|
||||
Content.visibility == "public",
|
||||
Content.user_id == user_id
|
||||
)
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if data.get("query"):
|
||||
query = f"%{data['query']}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Content.title.ilike(query),
|
||||
Content.description.ilike(query)
|
||||
)
|
||||
)
|
||||
|
||||
if data.get("content_type"):
|
||||
stmt = stmt.where(Content.content_type == data["content_type"])
|
||||
|
||||
if data.get("tags"):
|
||||
for tag in data["tags"]:
|
||||
stmt = stmt.where(Content.tags.contains([tag]))
|
||||
|
||||
if data.get("status"):
|
||||
stmt = stmt.where(Content.status == data["status"])
|
||||
|
||||
# Apply date filters
|
||||
if data.get("date_from"):
|
||||
stmt = stmt.where(Content.created_at >= datetime.fromisoformat(data["date_from"]))
|
||||
|
||||
if data.get("date_to"):
|
||||
stmt = stmt.where(Content.created_at <= datetime.fromisoformat(data["date_to"]))
|
||||
|
||||
# Apply pagination
|
||||
page = data.get("page", 1)
|
||||
per_page = min(data.get("per_page", 20), 100) # Max 100 items per page
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# Get total count
|
||||
from sqlalchemy import func
|
||||
count_stmt = select(func.count(Content.id)).select_from(stmt.subquery())
|
||||
total_result = await session.execute(count_stmt)
|
||||
total = total_result.scalar()
|
||||
|
||||
# Apply ordering and pagination
|
||||
if data.get("sort_by") == "created_at":
|
||||
stmt = stmt.order_by(Content.created_at.desc())
|
||||
elif data.get("sort_by") == "title":
|
||||
stmt = stmt.order_by(Content.title.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Content.updated_at.desc())
|
||||
|
||||
stmt = stmt.offset(offset).limit(per_page)
|
||||
|
||||
# Execute query
|
||||
result = await session.execute(stmt)
|
||||
content_list = result.scalars().all()
|
||||
|
||||
# Prepare response
|
||||
search_results = {
|
||||
"results": [
|
||||
{
|
||||
"id": str(content.id),
|
||||
"title": content.title,
|
||||
"description": content.description,
|
||||
"content_type": content.content_type,
|
||||
"file_size": content.file_size,
|
||||
"status": content.status,
|
||||
"visibility": content.visibility,
|
||||
"tags": content.tags,
|
||||
"created_at": content.created_at.isoformat()
|
||||
} for content in content_list
|
||||
],
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page
|
||||
}
|
||||
}
|
||||
|
||||
# Cache results for 5 minutes
|
||||
await cache_manager.set(search_key, search_results, ttl=300)
|
||||
|
||||
return response.json(search_results)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Search failed",
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Search failed", "code": "SEARCH_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@content_bp.route("/<content_id:uuid>/download", methods=["GET"])
|
||||
@rate_limit(limit=50, window=3600) # 50 downloads per hour
|
||||
@require_auth(permissions=["content.download"])
|
||||
async def download_content(request: Request, content_id: UUID) -> ResponseStream:
|
||||
"""
|
||||
Secure content download with access control and logging.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
content_id: UUID of content to download
|
||||
|
||||
Returns:
|
||||
ResponseStream: File stream or error response
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Load content
|
||||
stmt = select(Content).where(Content.id == content_id)
|
||||
result = await session.execute(stmt)
|
||||
content = result.scalar_one_or_none()
|
||||
|
||||
if not content:
|
||||
return response.json(
|
||||
{"error": "Content not found", "code": "NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
# Check download permissions
|
||||
if not await _check_content_access_db(session, content, user_id, "download"):
|
||||
return response.json(
|
||||
{"error": "Access denied", "code": "ACCESS_DENIED"},
|
||||
status=403
|
||||
)
|
||||
|
||||
# Generate download token
|
||||
download_token = generate_access_token(
|
||||
{"content_id": str(content_id), "user_id": str(user_id)},
|
||||
expires_in=3600 # 1 hour
|
||||
)
|
||||
|
||||
# Log download activity
|
||||
await _update_access_stats(content_id, user_id, "download")
|
||||
|
||||
# Get storage manager and create download stream
|
||||
storage_manager = StorageManager()
|
||||
file_stream = await storage_manager.get_file_stream(content.file_path)
|
||||
|
||||
await logger.ainfo(
|
||||
"Content download initiated",
|
||||
content_id=str(content_id),
|
||||
user_id=str(user_id),
|
||||
filename=content.title
|
||||
)
|
||||
|
||||
return await response.stream(
|
||||
file_stream,
|
||||
headers={
|
||||
"Content-Type": content.content_type or "application/octet-stream",
|
||||
"Content-Disposition": f'attachment; filename="{content.title}"',
|
||||
"Content-Length": str(content.file_size),
|
||||
"X-Download-Token": download_token
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Download failed",
|
||||
content_id=str(content_id),
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Download failed", "code": "DOWNLOAD_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
async def _check_content_access(content_id: UUID, user_id: UUID, action: str) -> bool:
|
||||
"""Check user access to content from cache or database."""
|
||||
cache_manager = get_cache_manager()
|
||||
access_key = f"access:{content_id}:{user_id}:{action}"
|
||||
|
||||
cached_access = await cache_manager.get(access_key)
|
||||
if cached_access is not None:
|
||||
return cached_access
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
stmt = select(Content).where(Content.id == content_id)
|
||||
result = await session.execute(stmt)
|
||||
content = result.scalar_one_or_none()
|
||||
|
||||
if not content:
|
||||
return False
|
||||
|
||||
has_access = await _check_content_access_db(session, content, user_id, action)
|
||||
|
||||
# Cache result for 5 minutes
|
||||
await cache_manager.set(access_key, has_access, ttl=300)
|
||||
|
||||
return has_access
|
||||
|
||||
async def _check_content_access_db(session, content: Content, user_id: UUID, action: str) -> bool:
|
||||
"""Check user access to content in database."""
|
||||
# Content owner always has access
|
||||
if content.user_id == user_id:
|
||||
return True
|
||||
|
||||
# Public content allows read access
|
||||
if content.visibility == "public" and action in ["read", "view"]:
|
||||
return True
|
||||
|
||||
# Check explicit access controls
|
||||
stmt = (
|
||||
select(ContentAccess)
|
||||
.where(
|
||||
and_(
|
||||
ContentAccess.content_id == content.id,
|
||||
ContentAccess.user_id == user_id,
|
||||
ContentAccess.permission == action,
|
||||
ContentAccess.expires_at > datetime.utcnow()
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
access_control = result.scalar_one_or_none()
|
||||
|
||||
return access_control is not None
|
||||
|
||||
async def _update_access_stats(content_id: UUID, user_id: UUID, action: str) -> None:
|
||||
"""Update content access statistics."""
|
||||
try:
|
||||
cache_manager = get_cache_manager()
|
||||
|
||||
# Update daily stats
|
||||
today = datetime.utcnow().date().isoformat()
|
||||
stats_key = f"stats:{content_id}:{action}:{today}"
|
||||
await cache_manager.increment(stats_key, ttl=86400)
|
||||
|
||||
# Update user activity
|
||||
user_activity_key = f"activity:{user_id}:{action}:{today}"
|
||||
await cache_manager.increment(user_activity_key, ttl=86400)
|
||||
|
||||
except Exception as e:
|
||||
await logger.awarning(
|
||||
"Failed to update access stats",
|
||||
content_id=str(content_id),
|
||||
user_id=str(user_id),
|
||||
action=action,
|
||||
error=str(e)
|
||||
)
|
||||
@@ -1,226 +0,0 @@
|
||||
"""Health check and system status endpoints."""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
from sanic import Blueprint, Request, response
|
||||
from sanic.response import JSONResponse
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import db_manager
|
||||
from app.core.metrics import get_metrics, get_metrics_content_type, metrics_collector
|
||||
from app.core.background.indexer_service import indexer_service
|
||||
from app.core.background.convert_service import convert_service
|
||||
from app.core.background.ton_service import ton_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
health_bp = Blueprint("health", version=1)
|
||||
|
||||
|
||||
@health_bp.route("/health", methods=["GET"])
|
||||
async def health_check(request: Request) -> JSONResponse:
|
||||
"""Basic health check endpoint."""
|
||||
return response.json({
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"service": "my-uploader-bot",
|
||||
"version": "2.0.0"
|
||||
})
|
||||
|
||||
|
||||
@health_bp.route("/health/detailed", methods=["GET"])
|
||||
async def detailed_health_check(request: Request) -> JSONResponse:
|
||||
"""Detailed health check with component status."""
|
||||
health_status = {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"service": "my-uploader-bot",
|
||||
"version": "2.0.0",
|
||||
"components": {}
|
||||
}
|
||||
|
||||
overall_healthy = True
|
||||
|
||||
# Database health
|
||||
try:
|
||||
async with db_manager.get_session() as session:
|
||||
await session.execute("SELECT 1")
|
||||
health_status["components"]["database"] = {
|
||||
"status": "healthy",
|
||||
"message": "Database connection successful"
|
||||
}
|
||||
except Exception as e:
|
||||
health_status["components"]["database"] = {
|
||||
"status": "unhealthy",
|
||||
"message": f"Database error: {str(e)}"
|
||||
}
|
||||
overall_healthy = False
|
||||
|
||||
# Redis health
|
||||
try:
|
||||
import redis.asyncio as redis
|
||||
settings = get_settings()
|
||||
redis_client = redis.from_url(settings.redis_url)
|
||||
await redis_client.ping()
|
||||
await redis_client.close()
|
||||
|
||||
health_status["components"]["cache"] = {
|
||||
"status": "healthy",
|
||||
"message": "Redis connection successful"
|
||||
}
|
||||
except Exception as e:
|
||||
health_status["components"]["cache"] = {
|
||||
"status": "unhealthy",
|
||||
"message": f"Redis error: {str(e)}"
|
||||
}
|
||||
overall_healthy = False
|
||||
|
||||
# TON service health
|
||||
try:
|
||||
# Check if TON service is responsive
|
||||
test_result = await ton_service.ping()
|
||||
health_status["components"]["blockchain"] = {
|
||||
"status": "healthy" if test_result else "degraded",
|
||||
"message": "TON service available" if test_result else "TON service degraded"
|
||||
}
|
||||
if not test_result:
|
||||
overall_healthy = False
|
||||
except Exception as e:
|
||||
health_status["components"]["blockchain"] = {
|
||||
"status": "unhealthy",
|
||||
"message": f"TON service error: {str(e)}"
|
||||
}
|
||||
overall_healthy = False
|
||||
|
||||
# Background services health
|
||||
health_status["components"]["background_services"] = {
|
||||
"indexer": {
|
||||
"status": "healthy" if indexer_service.is_running else "stopped",
|
||||
"active_tasks": len([t for t in indexer_service.tasks if not t.done()])
|
||||
},
|
||||
"converter": {
|
||||
"status": "healthy" if convert_service.is_running else "stopped",
|
||||
"active_tasks": len([t for t in convert_service.tasks if not t.done()])
|
||||
}
|
||||
}
|
||||
|
||||
# Update overall status
|
||||
if not overall_healthy:
|
||||
health_status["status"] = "unhealthy"
|
||||
|
||||
status_code = 200 if overall_healthy else 503
|
||||
return response.json(health_status, status=status_code)
|
||||
|
||||
|
||||
@health_bp.route("/health/ready", methods=["GET"])
|
||||
async def readiness_check(request: Request) -> JSONResponse:
|
||||
"""Kubernetes readiness probe endpoint."""
|
||||
try:
|
||||
# Quick database check
|
||||
async with db_manager.get_session() as session:
|
||||
await session.execute("SELECT 1")
|
||||
|
||||
return response.json({
|
||||
"status": "ready",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
except Exception as e:
|
||||
return response.json({
|
||||
"status": "not_ready",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}, status=503)
|
||||
|
||||
|
||||
@health_bp.route("/health/live", methods=["GET"])
|
||||
async def liveness_check(request: Request) -> JSONResponse:
|
||||
"""Kubernetes liveness probe endpoint."""
|
||||
return response.json({
|
||||
"status": "alive",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
|
||||
@health_bp.route("/metrics", methods=["GET"])
|
||||
async def prometheus_metrics(request: Request):
|
||||
"""Prometheus metrics endpoint."""
|
||||
try:
|
||||
metrics_data = await get_metrics()
|
||||
return response.raw(
|
||||
metrics_data,
|
||||
content_type=get_metrics_content_type()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating metrics: {e}")
|
||||
return response.json({
|
||||
"error": "Failed to generate metrics"
|
||||
}, status=500)
|
||||
|
||||
|
||||
@health_bp.route("/stats", methods=["GET"])
|
||||
async def system_stats(request: Request) -> JSONResponse:
|
||||
"""System statistics endpoint."""
|
||||
try:
|
||||
stats = {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"uptime": metrics_collector.start_time,
|
||||
"services": {}
|
||||
}
|
||||
|
||||
# Get indexer stats
|
||||
try:
|
||||
indexer_stats = await indexer_service.get_indexing_stats()
|
||||
stats["services"]["indexer"] = indexer_stats
|
||||
except Exception as e:
|
||||
stats["services"]["indexer"] = {"error": str(e)}
|
||||
|
||||
# Get converter stats
|
||||
try:
|
||||
converter_stats = await convert_service.get_processing_stats()
|
||||
stats["services"]["converter"] = converter_stats
|
||||
except Exception as e:
|
||||
stats["services"]["converter"] = {"error": str(e)}
|
||||
|
||||
return response.json(stats)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting system stats: {e}")
|
||||
return response.json({
|
||||
"error": "Failed to get system stats"
|
||||
}, status=500)
|
||||
|
||||
|
||||
@health_bp.route("/debug/info", methods=["GET"])
|
||||
async def debug_info(request: Request) -> JSONResponse:
|
||||
"""Debug information endpoint (development only)."""
|
||||
settings = get_settings()
|
||||
|
||||
if settings.environment != "development":
|
||||
return response.json({
|
||||
"error": "Debug endpoint only available in development"
|
||||
}, status=403)
|
||||
|
||||
debug_data = {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"environment": settings.environment,
|
||||
"debug_mode": settings.debug,
|
||||
"database_url": settings.database_url.replace(
|
||||
settings.database_url.split('@')[0].split('//')[1],
|
||||
"***:***"
|
||||
) if '@' in settings.database_url else "***",
|
||||
"redis_url": settings.redis_url.replace(
|
||||
settings.redis_url.split('@')[0].split('//')[1],
|
||||
"***:***"
|
||||
) if '@' in settings.redis_url else "***",
|
||||
"storage_backend": settings.storage_backend,
|
||||
"ton_network": settings.ton_network,
|
||||
"active_tasks": {
|
||||
"indexer": len([t for t in indexer_service.tasks if not t.done()]),
|
||||
"converter": len([t for t in convert_service.tasks if not t.done()])
|
||||
}
|
||||
}
|
||||
|
||||
return response.json(debug_data)
|
||||
@@ -1,452 +0,0 @@
|
||||
"""MY Network Monitoring Sanic Blueprint - веб-интерфейс мониторинга сети."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any
|
||||
from pathlib import Path
|
||||
|
||||
from sanic import Blueprint, Request
|
||||
from sanic.response import json as json_response, html as html_response
|
||||
from sanic.exceptions import SanicException
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Создать blueprint для мониторинга
|
||||
bp = Blueprint("my_monitoring", url_prefix="/api/my/monitor")
|
||||
|
||||
|
||||
def get_node_service():
|
||||
"""Получить сервис ноды."""
|
||||
try:
|
||||
from app.core.my_network.node_service import get_node_service
|
||||
return get_node_service()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node service: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
async def monitoring_dashboard(request: Request):
|
||||
"""Главная страница мониторинга MY Network."""
|
||||
try:
|
||||
# Получить данные для дашборда
|
||||
node_service = get_node_service()
|
||||
|
||||
if not node_service:
|
||||
monitoring_data = {
|
||||
"status": "offline",
|
||||
"error": "MY Network service not available"
|
||||
}
|
||||
else:
|
||||
# Собрать данные со всех компонентов
|
||||
node_info = await node_service.get_node_info()
|
||||
peers_info = await node_service.get_peers_info()
|
||||
sync_status = await node_service.sync_manager.get_sync_status()
|
||||
|
||||
monitoring_data = {
|
||||
"status": "online",
|
||||
"node_info": node_info,
|
||||
"peers_info": peers_info,
|
||||
"sync_status": sync_status,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# Попробовать использовать шаблон
|
||||
try:
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
# Настроить Jinja2
|
||||
templates_dir = Path(__file__).parent.parent.parent / "templates"
|
||||
if templates_dir.exists():
|
||||
env = Environment(loader=FileSystemLoader(str(templates_dir)))
|
||||
template = env.get_template("my_network_monitor.html")
|
||||
|
||||
html_content = template.render(monitoring_data=monitoring_data)
|
||||
return html_response(html_content)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Template rendering failed: {e}")
|
||||
|
||||
# Fallback HTML если шаблоны не работают
|
||||
return html_response(generate_fallback_html(monitoring_data))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error rendering monitoring dashboard: {e}")
|
||||
return html_response(generate_fallback_html({"status": "error", "error": str(e)}))
|
||||
|
||||
|
||||
@bp.get("/ascii")
|
||||
async def get_ascii_status(request: Request):
|
||||
"""Получить ASCII статус сети."""
|
||||
try:
|
||||
node_service = get_node_service()
|
||||
|
||||
if not node_service:
|
||||
return json_response({"ascii": generate_offline_ascii(), "status": "offline"})
|
||||
|
||||
# Получить данные
|
||||
node_info = await node_service.get_node_info()
|
||||
peers_info = await node_service.get_peers_info()
|
||||
sync_status = await node_service.sync_manager.get_sync_status()
|
||||
|
||||
# Генерировать ASCII
|
||||
ascii_art = await generate_network_ascii(node_info, peers_info, sync_status)
|
||||
|
||||
return json_response({
|
||||
"ascii": ascii_art,
|
||||
"status": "online",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating ASCII status: {e}")
|
||||
return json_response({"ascii": generate_error_ascii(str(e)), "status": "error"})
|
||||
|
||||
|
||||
@bp.get("/live")
|
||||
async def live_monitoring_data(request: Request):
|
||||
"""Получить живые данные для мониторинга."""
|
||||
try:
|
||||
node_service = get_node_service()
|
||||
|
||||
if not node_service:
|
||||
return json_response(
|
||||
{"error": "MY Network service unavailable"},
|
||||
status=503
|
||||
)
|
||||
|
||||
# Получить свежие данные
|
||||
node_info = await node_service.get_node_info()
|
||||
peers_info = await node_service.get_peers_info()
|
||||
sync_status = await node_service.sync_manager.get_sync_status()
|
||||
|
||||
# Статистика сети
|
||||
network_stats = {
|
||||
"connected_peers": peers_info["peer_count"],
|
||||
"active_syncs": sync_status["active_syncs"],
|
||||
"queue_size": sync_status["queue_size"],
|
||||
"uptime": node_info["uptime"],
|
||||
"status": node_info["status"]
|
||||
}
|
||||
|
||||
return json_response({
|
||||
"success": True,
|
||||
"data": {
|
||||
"node_info": node_info,
|
||||
"network_stats": network_stats,
|
||||
"peers": peers_info["peers"][:10], # Показать только первые 10 пиров
|
||||
"sync_status": sync_status
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting live monitoring data: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def generate_network_ascii(node_info: Dict[str, Any], peers_info: Dict[str, Any], sync_status: Dict[str, Any]) -> str:
|
||||
"""Генерировать ASCII представление состояния сети."""
|
||||
|
||||
ascii_parts = []
|
||||
|
||||
# Заголовок
|
||||
ascii_parts.append("""
|
||||
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ MY NETWORK v2.0 ║
|
||||
║ Distributed Content Protocol ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
# Информация о ноде
|
||||
status_indicator = "🟢" if node_info.get("status") == "running" else "🔴"
|
||||
uptime_hours = int(node_info.get("uptime", 0) / 3600)
|
||||
|
||||
ascii_parts.append(f"""
|
||||
┌─ NODE STATUS ────────────────────────────────────────────────────────────────┐
|
||||
│ Node ID: {node_info.get('node_id', 'unknown')[:16]}... │
|
||||
│ Status: {status_indicator} {node_info.get('status', 'unknown').upper()} │
|
||||
│ Uptime: {uptime_hours}h {int((node_info.get('uptime', 0) % 3600) / 60)}m │
|
||||
│ Version: MY Network {node_info.get('version', '2.0')} │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
""")
|
||||
|
||||
# Информация о пирах
|
||||
peer_count = peers_info.get("peer_count", 0)
|
||||
peer_status = "🌐" if peer_count > 0 else "🏝️"
|
||||
|
||||
ascii_parts.append(f"""
|
||||
┌─ NETWORK STATUS ─────────────────────────────────────────────────────────────┐
|
||||
│ Connected Peers: {peer_status} {peer_count:>3} │
|
||||
│ Known Nodes: {len(peers_info.get('peers', [])):>3} │
|
||||
│ Network Health: {'CONNECTED' if peer_count > 0 else 'ISOLATED':>9} │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
""")
|
||||
|
||||
# Статус синхронизации
|
||||
sync_running = sync_status.get("is_running", False)
|
||||
active_syncs = sync_status.get("active_syncs", 0)
|
||||
queue_size = sync_status.get("queue_size", 0)
|
||||
|
||||
sync_indicator = "⚡" if sync_running else "⏸️"
|
||||
|
||||
ascii_parts.append(f"""
|
||||
┌─ SYNC STATUS ────────────────────────────────────────────────────────────────┐
|
||||
│ Sync Engine: {sync_indicator} {'RUNNING' if sync_running else 'STOPPED':>7} │
|
||||
│ Active Syncs: {active_syncs:>3} │
|
||||
│ Queue Size: {queue_size:>3} │
|
||||
│ Workers: {sync_status.get('workers_count', 0):>3} │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
""")
|
||||
|
||||
# Подвал
|
||||
current_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
ascii_parts.append(f"""
|
||||
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ Last Updated: {current_time} ║
|
||||
║ MY Network Protocol - Decentralized Content Distribution System ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
return "".join(ascii_parts)
|
||||
|
||||
|
||||
def generate_offline_ascii() -> str:
|
||||
"""Генерировать ASCII для офлайн состояния."""
|
||||
return """
|
||||
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ MY NETWORK v2.0 ║
|
||||
║ Distributed Content Protocol ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
┌─ SYSTEM STATUS ──────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🔴 OFFLINE │
|
||||
│ │
|
||||
│ MY Network service is not available │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ Status: OFFLINE - Service not initialized ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
"""
|
||||
|
||||
|
||||
def generate_error_ascii(error_message: str) -> str:
|
||||
"""Генерировать ASCII для ошибки."""
|
||||
return f"""
|
||||
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ MY NETWORK v2.0 ║
|
||||
║ Distributed Content Protocol ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
┌─ ERROR STATE ────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ❌ ERROR │
|
||||
│ │
|
||||
│ {error_message[:64]:^64} │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
║ Status: ERROR - Check system logs for details ║
|
||||
╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
"""
|
||||
|
||||
|
||||
def generate_fallback_html(monitoring_data: Dict[str, Any]) -> str:
|
||||
"""Генерировать fallback HTML если шаблоны не работают."""
|
||||
|
||||
status = monitoring_data.get("status", "unknown")
|
||||
error_message = monitoring_data.get("error", "")
|
||||
|
||||
# Генерировать информацию о статусе
|
||||
if status == "online":
|
||||
node_info = monitoring_data.get("node_info", {})
|
||||
peers_info = monitoring_data.get("peers_info", {})
|
||||
sync_status = monitoring_data.get("sync_status", {})
|
||||
|
||||
status_info = f"""
|
||||
<div class="status-section">
|
||||
<h3>Node Status</h3>
|
||||
<ul>
|
||||
<li>Node ID: {node_info.get('node_id', 'unknown')[:16]}...</li>
|
||||
<li>Status: {node_info.get('status', 'unknown').upper()}</li>
|
||||
<li>Uptime: {int(node_info.get('uptime', 0) / 3600)}h {int((node_info.get('uptime', 0) % 3600) / 60)}m</li>
|
||||
<li>Version: MY Network {node_info.get('version', '2.0')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<h3>Network Status</h3>
|
||||
<ul>
|
||||
<li>Connected Peers: {peers_info.get('peer_count', 0)}</li>
|
||||
<li>Known Nodes: {len(peers_info.get('peers', []))}</li>
|
||||
<li>Network Health: {'CONNECTED' if peers_info.get('peer_count', 0) > 0 else 'ISOLATED'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<h3>Sync Status</h3>
|
||||
<ul>
|
||||
<li>Sync Engine: {'RUNNING' if sync_status.get('is_running', False) else 'STOPPED'}</li>
|
||||
<li>Active Syncs: {sync_status.get('active_syncs', 0)}</li>
|
||||
<li>Queue Size: {sync_status.get('queue_size', 0)}</li>
|
||||
<li>Workers: {sync_status.get('workers_count', 0)}</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
status_info = f"""
|
||||
<div class="error-section">
|
||||
<h3>Status: {status.upper()}</h3>
|
||||
<p>{error_message if error_message else 'MY Network service not available'}</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return f'''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MY Network Monitor</title>
|
||||
<style>
|
||||
body {{
|
||||
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #16213e 100%);
|
||||
color: #00ff41;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
}}
|
||||
|
||||
.container {{
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}}
|
||||
|
||||
.header {{
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 2px solid #00ff41;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 255, 65, 0.05);
|
||||
}}
|
||||
|
||||
.header h1 {{
|
||||
font-size: 2.5em;
|
||||
text-shadow: 0 0 10px #00ff41;
|
||||
margin: 0;
|
||||
}}
|
||||
|
||||
.status-section {{
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: 1px solid #00ff41;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
|
||||
.status-section h3 {{
|
||||
color: #00ff41;
|
||||
margin-bottom: 15px;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid #00ff41;
|
||||
padding-bottom: 5px;
|
||||
}}
|
||||
|
||||
.status-section ul {{
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}}
|
||||
|
||||
.status-section li {{
|
||||
margin: 10px 0;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px dotted #333;
|
||||
}}
|
||||
|
||||
.error-section {{
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid #ff0000;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
.error-section h3 {{
|
||||
color: #ff0000;
|
||||
margin-bottom: 15px;
|
||||
}}
|
||||
|
||||
.controls {{
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}}
|
||||
|
||||
.btn {{
|
||||
background: linear-gradient(45deg, #00ff41, #00cc33);
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
text-transform: uppercase;
|
||||
margin: 0 10px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}}
|
||||
|
||||
.btn:hover {{
|
||||
background: linear-gradient(45deg, #00cc33, #00ff41);
|
||||
}}
|
||||
|
||||
.footer {{
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #00ff41;
|
||||
color: #888;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>MY NETWORK MONITOR</h1>
|
||||
<p>Distributed Content Protocol v2.0</p>
|
||||
<p>Last Update: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}</p>
|
||||
</div>
|
||||
|
||||
{status_info}
|
||||
|
||||
<div class="controls">
|
||||
<a href="/api/my/monitor/" class="btn">🔄 REFRESH</a>
|
||||
<a href="/api/my/monitor/ascii" class="btn">📊 ASCII VIEW</a>
|
||||
<a href="/api/my/node/info" class="btn">ℹ️ NODE INFO</a>
|
||||
<a href="/api/my/health" class="btn">❤️ HEALTH</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>MY Network Protocol - Decentralized Content Distribution System</p>
|
||||
<p>Real-time monitoring dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Автообновление каждые 30 секунд
|
||||
setTimeout(() => location.reload(), 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
@@ -1,426 +0,0 @@
|
||||
"""MY Network Sanic Blueprint - маршруты для работы с распределенной сетью."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from sanic import Blueprint, Request
|
||||
from sanic.response import json as json_response, file as file_response
|
||||
from sanic.exceptions import SanicException
|
||||
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Создать blueprint для MY Network API
|
||||
bp = Blueprint("my_network", url_prefix="/api/my")
|
||||
|
||||
|
||||
def get_node_service():
|
||||
"""Получить сервис ноды."""
|
||||
try:
|
||||
from app.core.my_network.node_service import get_node_service
|
||||
return get_node_service()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node service: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@bp.get("/node/info")
|
||||
async def get_node_info(request: Request):
|
||||
"""Получить информацию о текущей ноде."""
|
||||
try:
|
||||
node_service = get_node_service()
|
||||
|
||||
if not node_service:
|
||||
return json_response(
|
||||
{"error": "MY Network service not available"},
|
||||
status=503
|
||||
)
|
||||
|
||||
node_info = await node_service.get_node_info()
|
||||
|
||||
return json_response({
|
||||
"success": True,
|
||||
"data": node_info,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node info: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@bp.get("/node/peers")
|
||||
async def get_node_peers(request: Request):
|
||||
"""Получить список подключенных пиров."""
|
||||
try:
|
||||
node_service = get_node_service()
|
||||
|
||||
if not node_service:
|
||||
return json_response(
|
||||
{"error": "MY Network service not available"},
|
||||
status=503
|
||||
)
|
||||
|
||||
peers_info = await node_service.get_peers_info()
|
||||
|
||||
return json_response({
|
||||
"success": True,
|
||||
"data": {
|
||||
"connected_peers": peers_info["connected_peers"],
|
||||
"peer_count": peers_info["peer_count"],
|
||||
"peers": peers_info["peers"]
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting peers: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@bp.post("/node/peers/connect")
|
||||
async def connect_to_peer(request: Request):
|
||||
"""Подключиться к новому пиру."""
|
||||
try:
|
||||
peer_data = request.json
|
||||
peer_address = peer_data.get("address")
|
||||
|
||||
if not peer_address:
|
||||
return json_response({"error": "Peer address is required"}, status=400)
|
||||
|
||||
node_service = get_node_service()
|
||||
if not node_service:
|
||||
return json_response(
|
||||
{"error": "MY Network service not available"},
|
||||
status=503
|
||||
)
|
||||
|
||||
success = await node_service.peer_manager.connect_to_peer(peer_address)
|
||||
|
||||
if success:
|
||||
return json_response({
|
||||
"success": True,
|
||||
"message": f"Successfully connected to peer: {peer_address}",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
else:
|
||||
return json_response({"error": "Failed to connect to peer"}, status=400)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting to peer: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@bp.delete("/node/peers/<peer_id>")
|
||||
async def disconnect_peer(request: Request, peer_id: str):
|
||||
"""Отключиться от пира."""
|
||||
try:
|
||||
node_service = get_node_service()
|
||||
if not node_service:
|
||||
return json_response(
|
||||
{"error": "MY Network service not available"},
|
||||
status=503
|
||||
)
|
||||
|
||||
success = await node_service.peer_manager.disconnect_peer(peer_id)
|
||||
|
||||
if success:
|
||||
return json_response({
|
||||
"success": True,
|
||||
"message": f"Successfully disconnected from peer: {peer_id}",
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
else:
|
||||
return json_response(
|
||||
{"error": "Peer not found or already disconnected"},
|
||||
status=404
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error disconnecting peer: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@bp.get("/content/list")
|
||||
async def get_content_list(request: Request):
|
||||
"""Получить список доступного контента."""
|
||||
try:
|
||||
# Получить параметры запроса
|
||||
limit = min(int(request.args.get("limit", 100)), 1000)
|
||||
offset = max(int(request.args.get("offset", 0)), 0)
|
||||
|
||||
# Кэшировать результат на 5 минут
|
||||
from app.core.cache import cache
|
||||
cache_key = f"my_network:content_list:{limit}:{offset}"
|
||||
cached_result = await cache.get(cache_key)
|
||||
|
||||
if cached_result:
|
||||
return json_response(json.loads(cached_result))
|
||||
|
||||
# Получить контент из БД
|
||||
from app.core.database import db_manager
|
||||
from app.core.models.content_compatible import Content, ContentMetadata
|
||||
from sqlalchemy import select, func
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
stmt = (
|
||||
select(Content, ContentMetadata)
|
||||
.outerjoin(ContentMetadata, Content.id == ContentMetadata.content_id)
|
||||
.where(Content.is_active == True)
|
||||
.order_by(Content.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
content_items = []
|
||||
|
||||
for content, metadata in result:
|
||||
content_data = {
|
||||
"hash": content.sha256_hash or content.md5_hash,
|
||||
"filename": content.filename,
|
||||
"original_filename": content.original_filename,
|
||||
"file_size": content.file_size,
|
||||
"file_type": content.file_type,
|
||||
"mime_type": content.mime_type,
|
||||
"created_at": content.created_at.isoformat(),
|
||||
"encrypted": getattr(content, 'encrypted', False),
|
||||
"metadata": metadata.to_dict() if metadata else {}
|
||||
}
|
||||
content_items.append(content_data)
|
||||
|
||||
# Получить общее количество
|
||||
count_stmt = select(func.count(Content.id)).where(Content.is_active == True)
|
||||
count_result = await session.execute(count_stmt)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
response_data = {
|
||||
"success": True,
|
||||
"data": {
|
||||
"content": content_items,
|
||||
"total": total_count,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
# Кэшировать результат
|
||||
await cache.set(cache_key, json.dumps(response_data), expire=300)
|
||||
|
||||
return json_response(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting content list: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@bp.get("/content/<content_hash>/exists")
|
||||
async def check_content_exists(request: Request, content_hash: str):
|
||||
"""Проверить существование контента по хешу."""
|
||||
try:
|
||||
# Кэшировать результат на 30 минут
|
||||
from app.core.cache import cache
|
||||
cache_key = f"my_network:content_exists:{content_hash}"
|
||||
cached_result = await cache.get(cache_key)
|
||||
|
||||
if cached_result is not None:
|
||||
return json_response({"exists": cached_result == "true", "hash": content_hash})
|
||||
|
||||
# Проверить в БД
|
||||
from app.core.database import db_manager
|
||||
from app.core.models.content_compatible import Content
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
stmt = select(Content.id).where(
|
||||
and_(
|
||||
Content.is_active == True,
|
||||
(Content.md5_hash == content_hash) | (Content.sha256_hash == content_hash)
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
exists = result.scalar_one_or_none() is not None
|
||||
|
||||
# Кэшировать результат
|
||||
await cache.set(cache_key, "true" if exists else "false", expire=1800)
|
||||
|
||||
return json_response({
|
||||
"exists": exists,
|
||||
"hash": content_hash,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking content existence: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@bp.get("/sync/status")
|
||||
async def get_sync_status(request: Request):
|
||||
"""Получить статус синхронизации."""
|
||||
try:
|
||||
node_service = get_node_service()
|
||||
if not node_service:
|
||||
return json_response(
|
||||
{"error": "MY Network service not available"},
|
||||
status=503
|
||||
)
|
||||
|
||||
sync_status = await node_service.sync_manager.get_sync_status()
|
||||
|
||||
return json_response({
|
||||
"success": True,
|
||||
"data": sync_status,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting sync status: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@bp.post("/sync/start")
|
||||
async def start_network_sync(request: Request):
|
||||
"""Запустить синхронизацию с сетью."""
|
||||
try:
|
||||
node_service = get_node_service()
|
||||
if not node_service:
|
||||
return json_response(
|
||||
{"error": "MY Network service not available"},
|
||||
status=503
|
||||
)
|
||||
|
||||
sync_result = await node_service.sync_manager.sync_with_network()
|
||||
|
||||
return json_response({
|
||||
"success": True,
|
||||
"data": sync_result,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting network sync: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@bp.get("/network/stats")
|
||||
async def get_network_stats(request: Request):
|
||||
"""Получить статистику сети."""
|
||||
try:
|
||||
node_service = get_node_service()
|
||||
if not node_service:
|
||||
return json_response(
|
||||
{"error": "MY Network service not available"},
|
||||
status=503
|
||||
)
|
||||
|
||||
# Получить информацию о ноде и пирах
|
||||
node_info = await node_service.get_node_info()
|
||||
peers_info = await node_service.get_peers_info()
|
||||
sync_status = await node_service.sync_manager.get_sync_status()
|
||||
|
||||
# Статистика контента
|
||||
from app.core.database import db_manager
|
||||
from app.core.models.content_compatible import Content
|
||||
from sqlalchemy import select, func
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
# Общее количество контента
|
||||
content_count_stmt = select(func.count(Content.id)).where(Content.is_active == True)
|
||||
content_count_result = await session.execute(content_count_stmt)
|
||||
total_content = content_count_result.scalar()
|
||||
|
||||
# Размер контента
|
||||
size_stmt = select(func.sum(Content.file_size)).where(Content.is_active == True)
|
||||
size_result = await session.execute(size_stmt)
|
||||
total_size = size_result.scalar() or 0
|
||||
|
||||
# Контент по типам
|
||||
type_stmt = select(Content.file_type, func.count(Content.id)).where(Content.is_active == True).group_by(Content.file_type)
|
||||
type_result = await session.execute(type_stmt)
|
||||
content_by_type = {row[0]: row[1] for row in type_result}
|
||||
|
||||
network_stats = {
|
||||
"node_info": {
|
||||
"node_id": node_info["node_id"],
|
||||
"uptime": node_info["uptime"],
|
||||
"version": node_info["version"],
|
||||
"status": node_info["status"]
|
||||
},
|
||||
"network": {
|
||||
"connected_peers": peers_info["peer_count"],
|
||||
"known_peers": len(peers_info["peers"]),
|
||||
"network_health": "good" if peers_info["peer_count"] > 0 else "isolated"
|
||||
},
|
||||
"content": {
|
||||
"total_items": total_content,
|
||||
"total_size_bytes": total_size,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||
"content_by_type": content_by_type
|
||||
},
|
||||
"sync": {
|
||||
"active_syncs": sync_status["active_syncs"],
|
||||
"queue_size": sync_status["queue_size"],
|
||||
"is_running": sync_status["is_running"]
|
||||
}
|
||||
}
|
||||
|
||||
return json_response({
|
||||
"success": True,
|
||||
"data": network_stats,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting network stats: {e}")
|
||||
return json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
@bp.get("/health")
|
||||
async def health_check(request: Request):
|
||||
"""Проверка здоровья MY Network ноды."""
|
||||
try:
|
||||
node_service = get_node_service()
|
||||
|
||||
# Базовая проверка сервисов
|
||||
health_status = {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"services": {
|
||||
"node_service": node_service is not None,
|
||||
"peer_manager": hasattr(node_service, 'peer_manager') if node_service else False,
|
||||
"sync_manager": hasattr(node_service, 'sync_manager') if node_service else False,
|
||||
"database": True # Если дошли до этой точки, БД работает
|
||||
}
|
||||
}
|
||||
|
||||
# Проверить подключение к пирам
|
||||
if node_service:
|
||||
peers_info = await node_service.get_peers_info()
|
||||
health_status["network"] = {
|
||||
"connected_peers": peers_info["peer_count"],
|
||||
"status": "connected" if peers_info["peer_count"] > 0 else "isolated"
|
||||
}
|
||||
|
||||
# Определить общий статус
|
||||
if not all(health_status["services"].values()):
|
||||
health_status["status"] = "unhealthy"
|
||||
elif node_service and peers_info["peer_count"] == 0:
|
||||
health_status["status"] = "isolated"
|
||||
|
||||
return json_response(health_status)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}")
|
||||
return json_response({
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}, status=500)
|
||||
@@ -1,280 +0,0 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime
|
||||
from mimetypes import guess_type
|
||||
|
||||
import aiofiles
|
||||
import traceback
|
||||
from base58 import b58encode
|
||||
from sanic import response
|
||||
import json
|
||||
|
||||
from app.core._config import UPLOADS_DIR
|
||||
from app.core._utils.resolve_content import resolve_content
|
||||
from app.core.logger import make_log
|
||||
from app.core.models.node_storage import StoredContent
|
||||
from pydub import AudioSegment
|
||||
from PIL import Image
|
||||
from uuid import uuid4
|
||||
import subprocess
|
||||
|
||||
|
||||
# Производится загрузка любого контента одним запросом с определением mime_type по расширению
|
||||
# file_mimetype audio/video
|
||||
# extension_encoding file encode container
|
||||
# Файл сохраняется под sha256(file_content) !!, очень тяжело
|
||||
# генерируется CID с учетом типа контента и его декодирования
|
||||
# Загрузка происходит только от пользователя либо если наш же бэкенд просит загрузить что-то
|
||||
# Создание расшифрованного (local/content_bin) StoredContent
|
||||
|
||||
async def s_api_v1_storage_post(request):
|
||||
if not request.files:
|
||||
return response.json({"error": "No file provided"}, status=400)
|
||||
|
||||
file_param = list(request.files.values())[0][0] if request.files else None
|
||||
# file_name_json = request.json.get("filename") if request.json else None
|
||||
|
||||
if file_param:
|
||||
file_content = file_param.body
|
||||
file_name = file_param.name
|
||||
else:
|
||||
return response.json({"error": "No file provided"}, status=400)
|
||||
|
||||
file_meta = {}
|
||||
file_mimetype, file_encoding = guess_type(file_name)
|
||||
if file_mimetype:
|
||||
file_meta["content_type"] = file_mimetype
|
||||
|
||||
if file_encoding:
|
||||
file_meta["extension_encoding"] = file_encoding
|
||||
|
||||
try:
|
||||
file_hash_bin = hashlib.sha256(file_content).digest()
|
||||
file_hash = b58encode(file_hash_bin).decode()
|
||||
stored_content = request.ctx.db_session.query(StoredContent).filter(StoredContent.hash == file_hash).first()
|
||||
if stored_content:
|
||||
stored_cid = stored_content.cid.serialize_v1()
|
||||
stored_cid_v2 = stored_content.cid.serialize_v2()
|
||||
return response.json({
|
||||
"content_sha256": file_hash,
|
||||
"content_id_v1": stored_cid,
|
||||
"content_id": stored_cid_v2,
|
||||
"content_url": f"dmy://storage?cid={stored_cid_v2}"
|
||||
})
|
||||
|
||||
if request.ctx.user:
|
||||
pass
|
||||
elif request.ctx.verified_hash:
|
||||
assert request.ctx.verified_hash == file_hash_bin, "Invalid service request hash"
|
||||
else:
|
||||
return response.json({"error": "Unauthorized"}, status=401)
|
||||
|
||||
new_content = StoredContent(
|
||||
type="local/content_bin",
|
||||
user_id=request.ctx.user.id if request.ctx.user else None,
|
||||
hash=file_hash,
|
||||
filename=file_name,
|
||||
meta=file_meta,
|
||||
created=datetime.now(),
|
||||
key_id=None,
|
||||
)
|
||||
request.ctx.db_session.add(new_content)
|
||||
request.ctx.db_session.commit()
|
||||
|
||||
file_path = os.path.join(UPLOADS_DIR, file_hash)
|
||||
async with aiofiles.open(file_path, "wb") as file:
|
||||
await file.write(file_content)
|
||||
|
||||
new_content_id = new_content.cid
|
||||
new_cid_v1 = new_content_id.serialize_v1()
|
||||
new_cid = new_content_id.serialize_v2()
|
||||
|
||||
return response.json({
|
||||
"content_sha256": file_hash,
|
||||
"content_id": new_cid,
|
||||
"content_id_v1": new_cid_v1,
|
||||
"content_url": f"dmy://storage?cid={new_cid}",
|
||||
})
|
||||
except BaseException as e:
|
||||
make_log("Storage", f"Error: {e}" + '\n' + traceback.format_exc(), level="error")
|
||||
return response.json({"error": f"Error: {e}"}, status=500)
|
||||
|
||||
|
||||
# Получение контента с использованием seconds_limit по file_hash
|
||||
|
||||
async def s_api_v1_storage_get(request, file_hash=None):
|
||||
seconds_limit = int(request.args.get("seconds_limit", 0))
|
||||
|
||||
content_id = file_hash
|
||||
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("Storage", f"File {content_sha256} requested by {request.ctx.user}")
|
||||
file_path = os.path.join(UPLOADS_DIR, content_sha256)
|
||||
if not os.path.exists(file_path):
|
||||
make_log("Storage", f"File {content_sha256} not found locally", level="error")
|
||||
return response.json({"error": "File not found"}, status=404)
|
||||
|
||||
async with aiofiles.open(file_path, "rb") as file:
|
||||
content_file_bin = await file.read()
|
||||
|
||||
# query_id = str(uuid4().hex())
|
||||
tempfile_path = os.path.join(UPLOADS_DIR, f"tmp_{content_sha256}")
|
||||
|
||||
accept_type = cid.accept_type or content.meta.get("content_type")
|
||||
if accept_type:
|
||||
if accept_type == "application/json":
|
||||
return response.json(
|
||||
json.loads(content_file_bin.decode())
|
||||
)
|
||||
content_type, content_encoding = accept_type.split("/")
|
||||
if content_type == 'audio':
|
||||
tempfile_path += "_mpeg" + (f"_{seconds_limit}" if seconds_limit else "")
|
||||
if not os.path.exists(tempfile_path):
|
||||
try:
|
||||
cover_content = StoredContent.from_cid(content.meta.get('cover_cid'))
|
||||
cover_tempfile_path = os.path.join(UPLOADS_DIR, f"tmp_{cover_content.hash}_jpeg")
|
||||
if not os.path.exists(cover_tempfile_path):
|
||||
cover_image = Image.open(cover_content.filepath)
|
||||
cover_image = cover_image.convert('RGB')
|
||||
quality = 95
|
||||
while quality > 10:
|
||||
cover_image.save(cover_tempfile_path, 'JPEG', quality=quality)
|
||||
if os.path.getsize(cover_tempfile_path) <= 200 * 1024:
|
||||
break
|
||||
quality -= 5
|
||||
|
||||
assert os.path.exists(cover_tempfile_path), "Cover image not found"
|
||||
except:
|
||||
cover_content = None
|
||||
cover_tempfile_path = None
|
||||
|
||||
try:
|
||||
file_ext = content.filename.split('.')[-1]
|
||||
if file_ext == 'mp3':
|
||||
audio = AudioSegment.from_mp3(file_path)
|
||||
elif file_ext == 'wav':
|
||||
audio = AudioSegment.from_wav(file_path)
|
||||
elif file_ext == 'ogg':
|
||||
audio = AudioSegment.from_ogg(file_path)
|
||||
elif file_ext == 'flv':
|
||||
audio = AudioSegment.from_flv(file_path)
|
||||
else:
|
||||
audio = None
|
||||
|
||||
if not audio:
|
||||
try:
|
||||
audio = AudioSegment.from_file(file_path)
|
||||
except BaseException as e:
|
||||
make_log("Storage", f"Error loading audio from file: {e}", level="debug")
|
||||
|
||||
if not audio:
|
||||
try:
|
||||
audio = AudioSegment(content_file_bin)
|
||||
except BaseException as e:
|
||||
make_log("Storage", f"Error loading audio from binary: {e}", level="debug")
|
||||
|
||||
audio = audio[:seconds_limit * 1000] if seconds_limit else audio
|
||||
audio.export(tempfile_path, format="mp3", cover=cover_tempfile_path)
|
||||
except BaseException as e:
|
||||
make_log("Storage", f"Error converting audio: {e}" + '\n' + traceback.format_exc(), level="error")
|
||||
|
||||
if os.path.exists(tempfile_path):
|
||||
async with aiofiles.open(tempfile_path, "rb") as file:
|
||||
content_file_bin = await file.read()
|
||||
|
||||
accept_type = 'audio/mpeg'
|
||||
make_log("Storage", f"Audio {content_sha256} converted successfully")
|
||||
else:
|
||||
tempfile_path = tempfile_path[:-5]
|
||||
|
||||
elif content_type == 'image':
|
||||
tempfile_path += "_jpeg"
|
||||
if not os.path.exists(tempfile_path):
|
||||
try:
|
||||
image = Image.open(file_path)
|
||||
image = image.convert('RGB')
|
||||
quality = 95
|
||||
while quality > 10:
|
||||
image.save(tempfile_path, 'JPEG', quality=quality)
|
||||
if os.path.getsize(tempfile_path) <= 200 * 1024:
|
||||
break
|
||||
quality -= 5
|
||||
except BaseException as e:
|
||||
make_log("Storage", f"Error converting image: {e}" + '\n' + traceback.format_exc(), level="error")
|
||||
|
||||
if os.path.exists(tempfile_path):
|
||||
async with aiofiles.open(tempfile_path, "rb") as file:
|
||||
content_file_bin = await file.read()
|
||||
|
||||
make_log("Storage", f"Image {content_sha256} converted successfully")
|
||||
accept_type = 'image/jpeg'
|
||||
else:
|
||||
tempfile_path = tempfile_path[:-5]
|
||||
|
||||
elif content_type == 'video':
|
||||
# Build a temp path for the video
|
||||
tempfile_path += "_mp4" + (f"_{seconds_limit}" if seconds_limit else "") + ".mp4"
|
||||
if not os.path.exists(tempfile_path):
|
||||
try:
|
||||
# Use ffmpeg to cut or convert to mp4
|
||||
if seconds_limit > 0:
|
||||
# Cut the video to the specified seconds_limit
|
||||
subprocess.run([
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss", "0", # Set start time (fast seeking)
|
||||
"-i", file_path,
|
||||
"-t", str(seconds_limit), # Set duration of the output
|
||||
"-c:v", "libx264", # Encode video with libx264
|
||||
"-profile:v", "baseline", # Set baseline profile for compatibility with Telegram
|
||||
"-level", "3.0", # Set level to 3.0 for compatibility
|
||||
"-pix_fmt", "yuv420p", # Set pixel format for maximum compatibility
|
||||
"-c:a", "aac", # Encode audio with AAC
|
||||
"-b:a", "128k", # Set audio bitrate
|
||||
"-movflags", "+faststart", # Enable fast start for streaming
|
||||
tempfile_path
|
||||
], check=True)
|
||||
else:
|
||||
# Just convert to mp4 (no cutting)
|
||||
subprocess.run([
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss", "0", # Set start time (fast seeking)
|
||||
"-i", file_path,
|
||||
# "-t", str(seconds_limit), # Set duration of the output
|
||||
"-c:v", "libx264", # Encode video with libx264
|
||||
"-profile:v", "baseline", # Set baseline profile for compatibility with Telegram
|
||||
"-level", "3.0", # Set level to 3.0 for compatibility
|
||||
"-pix_fmt", "yuv420p", # Set pixel format for maximum compatibility
|
||||
"-c:a", "aac", # Encode audio with AAC
|
||||
"-b:a", "128k", # Set audio bitrate
|
||||
"-movflags", "+faststart", # Enable fast start for streaming
|
||||
tempfile_path
|
||||
], check=True)
|
||||
except BaseException as e:
|
||||
make_log("Storage", f"Error converting video: {e}" + '\n' + traceback.format_exc(), level="error")
|
||||
|
||||
if os.path.exists(tempfile_path):
|
||||
async with aiofiles.open(tempfile_path, "rb") as file:
|
||||
content_file_bin = await file.read()
|
||||
make_log("Storage", f"Video {content_sha256} processed successfully")
|
||||
accept_type = 'video/mp4'
|
||||
else:
|
||||
tempfile_path = tempfile_path[:-4] # remove _mp4 or similar suffix
|
||||
|
||||
return response.raw(body=content_file_bin, **({'content_type': accept_type} if accept_type else {}))
|
||||
|
||||
async def s_api_v1_storage_decode_cid(request, content_id=None):
|
||||
cid, errmsg = resolve_content(content_id)
|
||||
if errmsg:
|
||||
return response.json({"error": errmsg}, status=400)
|
||||
|
||||
return response.json(cid.json_format())
|
||||
@@ -1,296 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import asyncio
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
from mimetypes import guess_type
|
||||
from base64 import b64decode
|
||||
|
||||
import aiofiles
|
||||
from base58 import b58encode
|
||||
from sanic import response
|
||||
|
||||
from app.core.logger import make_log
|
||||
from app.core.models.node_storage import StoredContent
|
||||
from app.core._config import UPLOADS_DIR
|
||||
from app.core._utils.resolve_content import resolve_content
|
||||
|
||||
|
||||
# POST /api/v1.5/storage
|
||||
async def s_api_v1_5_storage_post(request):
|
||||
# Log the receipt of a chunk upload request
|
||||
make_log("uploader_v1.5", "Received chunk upload request", level="INFO")
|
||||
|
||||
# Get the provided file name from header and decode it from base64
|
||||
provided_filename_b64 = request.headers.get("X-File-Name")
|
||||
if not provided_filename_b64:
|
||||
make_log("uploader_v1.5", "Missing X-File-Name header", level="ERROR")
|
||||
return response.json({"error": "Missing X-File-Name header"}, status=400)
|
||||
try:
|
||||
provided_filename = b64decode(provided_filename_b64).decode("utf-8")
|
||||
except Exception as e:
|
||||
make_log("uploader_v1.5", f"Invalid X-File-Name header: {e}", level="ERROR")
|
||||
return response.json({"error": "Invalid X-File-Name header"}, status=400)
|
||||
|
||||
# Get X-Chunk-Start header (must be provided) and parse it as integer
|
||||
chunk_start_header = request.headers.get("X-Chunk-Start")
|
||||
if chunk_start_header is None:
|
||||
make_log("uploader_v1.5", "Missing X-Chunk-Start header", level="ERROR")
|
||||
return response.json({"error": "Missing X-Chunk-Start header"}, status=400)
|
||||
try:
|
||||
chunk_start = int(chunk_start_header)
|
||||
except Exception as e:
|
||||
make_log("uploader_v1.5", f"Invalid X-Chunk-Start header: {e}", level="ERROR")
|
||||
return response.json({"error": "Invalid X-Chunk-Start header"}, status=400)
|
||||
|
||||
# Enforce maximum chunk size (80 MB) using Content-Length header if provided
|
||||
max_chunk_size = 80 * 1024 * 1024 # 80 MB
|
||||
content_length = request.headers.get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
content_length = int(content_length)
|
||||
if content_length > max_chunk_size:
|
||||
make_log("uploader_v1.5", f"Chunk size {content_length} exceeds maximum allowed", level="ERROR")
|
||||
return response.json({"error": "Chunk size exceeds maximum allowed (80 MB)"}, status=400)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Determine if this is a new upload or a continuation (resume)
|
||||
upload_id = request.headers.get("X-Upload-ID")
|
||||
is_new_upload = False
|
||||
if not upload_id:
|
||||
# New upload session: generate a new uuid
|
||||
upload_id = str(uuid4())
|
||||
is_new_upload = True
|
||||
make_log("uploader_v1.5", f"Starting new upload session with ID: {upload_id}", level="INFO")
|
||||
else:
|
||||
make_log("uploader_v1.5", f"Resuming upload session with ID: {upload_id}", level="INFO")
|
||||
|
||||
# Determine the temporary file path based on upload_id
|
||||
temp_path = os.path.join(UPLOADS_DIR, f"v1.5_upload_{upload_id}")
|
||||
|
||||
# Check current size of the temporary file (if it exists)
|
||||
current_size = 0
|
||||
if os.path.exists(temp_path):
|
||||
current_size = os.path.getsize(temp_path)
|
||||
|
||||
# If the provided chunk_start is less than current_size, the chunk is already received
|
||||
if chunk_start < current_size:
|
||||
make_log("uploader_v1.5", f"Chunk starting at {chunk_start} already received, current size: {current_size}", level="INFO")
|
||||
return response.json({"upload_id": upload_id, "current_size": current_size})
|
||||
elif chunk_start > current_size:
|
||||
make_log("uploader_v1.5", f"Chunk start {chunk_start} does not match current file size {current_size}", level="ERROR")
|
||||
return response.json({"error": "Chunk start does not match current file size"}, status=400)
|
||||
|
||||
# Append the received chunk to the temporary file
|
||||
try:
|
||||
mode = 'wb' if is_new_upload else 'ab'
|
||||
async with aiofiles.open(temp_path, mode) as out_file:
|
||||
data = request.body # Get the full body if available
|
||||
if data:
|
||||
await out_file.write(data) # Write the whole body at once
|
||||
else:
|
||||
async for chunk in request.stream:
|
||||
await out_file.write(chunk)
|
||||
new_size = os.path.getsize(temp_path)
|
||||
make_log("uploader_v1.5", f"Appended chunk. New file size: {new_size}", level="INFO")
|
||||
except Exception as e:
|
||||
make_log("uploader_v1.5", f"Error saving chunk: {e}", level="ERROR")
|
||||
return response.json({"error": "Failed to save chunk"}, status=500)
|
||||
|
||||
# If computed hash matches the provided one, the final chunk has been received
|
||||
is_last_chunk = int(request.headers.get("X-Last-Chunk", "0")) == 1
|
||||
if is_last_chunk:
|
||||
# Compute the SHA256 hash of the temporary file using subprocess
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
'sha256sum', temp_path,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
error_msg = stderr.decode().strip()
|
||||
make_log("uploader_v1.5", f"sha256sum error: {error_msg}", level="ERROR")
|
||||
return response.json({"error": "Failed to compute file hash"}, status=500)
|
||||
computed_hash_hex = stdout.decode().split()[0].strip()
|
||||
computed_hash_bytes = bytes.fromhex(computed_hash_hex)
|
||||
computed_hash_b58 = b58encode(computed_hash_bytes).decode()
|
||||
make_log("uploader_v1.5", f"Computed hash (base58): {computed_hash_b58}", level="INFO")
|
||||
except Exception as e:
|
||||
make_log("uploader_v1.5", f"Error computing file hash: {e}", level="ERROR")
|
||||
return response.json({"error": "Error computing file hash"}, status=500)
|
||||
|
||||
final_path = os.path.join(UPLOADS_DIR, f"{computed_hash_b58}")
|
||||
try:
|
||||
os.rename(temp_path, final_path)
|
||||
make_log("uploader_v1.5", f"Final chunk received. File renamed to: {final_path}", level="INFO")
|
||||
except Exception as e:
|
||||
make_log("uploader_v1.5", f"Error renaming file: {e}", level="ERROR")
|
||||
return response.json({"error": "Failed to finalize file storage"}, status=500)
|
||||
|
||||
db_session = request.ctx.db_session
|
||||
existing = db_session.query(StoredContent).filter_by(hash=computed_hash_b58).first()
|
||||
if existing:
|
||||
make_log("uploader_v1.5", f"File with hash {computed_hash_b58} already exists in DB", level="INFO")
|
||||
serialized_v2 = existing.cid.serialize_v2()
|
||||
serialized_v1 = existing.cid.serialize_v1()
|
||||
return response.json({
|
||||
"upload_id": upload_id,
|
||||
"content_sha256": computed_hash_b58,
|
||||
"content_id": serialized_v2,
|
||||
"content_id_v1": serialized_v1,
|
||||
"content_url": f"dmy://storage?cid={serialized_v2}",
|
||||
})
|
||||
|
||||
try:
|
||||
user_id = request.ctx.user.id if request.ctx.user else None
|
||||
|
||||
new_content = StoredContent(
|
||||
type='local/content_bin',
|
||||
hash=computed_hash_b58,
|
||||
user_id=user_id,
|
||||
filename=provided_filename,
|
||||
key_id=None,
|
||||
meta={},
|
||||
created=datetime.utcnow()
|
||||
)
|
||||
db_session.add(new_content)
|
||||
db_session.commit()
|
||||
make_log("uploader_v1.5", f"New file stored and indexed for user {user_id} with hash {computed_hash_b58}", level="INFO")
|
||||
except Exception as e:
|
||||
make_log("uploader_v1.5", f"Database error: {e}", level="ERROR")
|
||||
return response.json({"error": "Database error"}, status=500)
|
||||
|
||||
serialized_v2 = new_content.cid.serialize_v2()
|
||||
serialized_v1 = new_content.cid.serialize_v1()
|
||||
return response.json({
|
||||
"upload_id": upload_id,
|
||||
"content_sha256": computed_hash_b58,
|
||||
"content_id": serialized_v2,
|
||||
"content_id_v1": serialized_v1,
|
||||
"content_url": f"dmy://storage?cid={serialized_v2}",
|
||||
})
|
||||
else:
|
||||
# Not the final chunk yet – return current upload status
|
||||
return response.json({"upload_id": upload_id, "current_size": os.path.getsize(temp_path)})
|
||||
|
||||
|
||||
# GET /api/v1.5/storage/<file_hash>
|
||||
async def s_api_v1_5_storage_get(request, file_hash):
|
||||
make_log("uploader_v1.5", f"Received file retrieval request for hash: {file_hash}", level="INFO")
|
||||
|
||||
try:
|
||||
file_hash = b58encode(resolve_content(file_hash)[0].content_hash).decode()
|
||||
except:
|
||||
pass
|
||||
|
||||
final_path = os.path.join(UPLOADS_DIR, f"{file_hash}")
|
||||
if not os.path.exists(final_path):
|
||||
make_log("uploader_v1.5", f"File not found: {final_path}", level="ERROR")
|
||||
return response.json({"error": "File not found"}, status=404)
|
||||
|
||||
db_session = request.ctx.db_session
|
||||
stored = db_session.query(StoredContent).filter_by(hash=file_hash).first()
|
||||
if stored and stored.filename:
|
||||
filename_for_mime = stored.filename
|
||||
else:
|
||||
filename_for_mime = final_path
|
||||
|
||||
mime_type, _ = guess_type(filename_for_mime)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
file_size = os.path.getsize(final_path)
|
||||
range_header = request.headers.get("Range")
|
||||
|
||||
if range_header:
|
||||
make_log("uploader_v1.5", f"Processing Range header: {range_header}", level="INFO")
|
||||
range_spec = range_header.strip().lower()
|
||||
if not range_spec.startswith("bytes="):
|
||||
make_log("uploader_v1.5", f"Invalid Range header: {range_header}", level="ERROR")
|
||||
return response.json({"error": "Invalid Range header"}, status=400)
|
||||
range_spec = range_spec[len("bytes="):]
|
||||
range_parts = [part.strip() for part in range_spec.split(',')]
|
||||
parsed_ranges = []
|
||||
try:
|
||||
for part in range_parts:
|
||||
if '-' not in part:
|
||||
raise ValueError("Invalid range format")
|
||||
start_str, end_str = part.split('-', 1)
|
||||
if start_str == "":
|
||||
suffix_length = int(end_str)
|
||||
start = 0 if suffix_length > file_size else file_size - suffix_length
|
||||
end = file_size - 1
|
||||
else:
|
||||
start = int(start_str)
|
||||
end = file_size - 1 if end_str == "" else int(end_str)
|
||||
if start > end or end >= file_size:
|
||||
raise ValueError("Requested Range Not Satisfiable")
|
||||
parsed_ranges.append((start, end))
|
||||
except Exception as e:
|
||||
make_log("uploader_v1.5", f"Invalid Range header: {range_header} - {e}", level="ERROR")
|
||||
return response.json({"error": "Invalid Range header"}, status=400)
|
||||
|
||||
if len(parsed_ranges) == 1:
|
||||
# Single range streaming
|
||||
start, end = parsed_ranges[0]
|
||||
content_length = end - start + 1
|
||||
headers = {
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(content_length),
|
||||
"Content-Type": mime_type,
|
||||
}
|
||||
# Create response for streaming
|
||||
stream_response = await request.respond(headers=headers, status=206, content_type=mime_type)
|
||||
make_log("uploader_v1.5", f"Starting to stream file from byte {start} to {end}", level="INFO")
|
||||
async with aiofiles.open(final_path, mode='rb') as f:
|
||||
await f.seek(start)
|
||||
remaining = content_length
|
||||
chunk_size = 1024 * 1024 # chunk size in bytes
|
||||
while remaining > 0:
|
||||
read_size = min(chunk_size, remaining)
|
||||
data = await f.read(read_size)
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
await stream_response.send(data)
|
||||
make_log("uploader_v1.5", f"Finished streaming file: {final_path}", level="INFO")
|
||||
await stream_response.eof()
|
||||
return stream_response
|
||||
else:
|
||||
# Multipart range streaming
|
||||
boundary = uuid4().hex
|
||||
headers = {
|
||||
"Content-Type": f"multipart/byteranges; boundary={boundary}",
|
||||
"Accept-Ranges": "bytes",
|
||||
}
|
||||
stream_response = await request.respond(headers=headers, status=206)
|
||||
for start, end in parsed_ranges:
|
||||
part_header = (
|
||||
f"--{boundary}\r\n"
|
||||
f"Content-Type: {mime_type}\r\n"
|
||||
f"Content-Range: bytes {start}-{end}/{file_size}\r\n"
|
||||
f"\r\n"
|
||||
)
|
||||
await stream_response.send(part_header.encode())
|
||||
part_length = end - start + 1
|
||||
async with aiofiles.open(final_path, mode='rb') as f:
|
||||
await f.seek(start)
|
||||
remaining = part_length
|
||||
chunk_size = 1024 * 1024
|
||||
while remaining > 0:
|
||||
read_size = min(chunk_size, remaining)
|
||||
data = await f.read(read_size)
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
await stream_response.send(data)
|
||||
await stream_response.send(b"\r\n")
|
||||
await stream_response.send(f"--{boundary}--\r\n".encode())
|
||||
await stream_response.eof()
|
||||
return stream_response
|
||||
else:
|
||||
make_log("uploader_v1.5", f"Returning full file for video/audio: {final_path}", level="INFO")
|
||||
return await response.file(final_path, mime_type=mime_type)
|
||||
@@ -1,18 +0,0 @@
|
||||
from sanic import response
|
||||
|
||||
from app.core._config import PROJECT_HOST
|
||||
|
||||
|
||||
async def s_api_tonconnect_manifest(request):
|
||||
return response.json({
|
||||
"url": f"{PROJECT_HOST}/#from=tonconnect",
|
||||
"name": "@MY Node",
|
||||
"iconUrl": "https://github.com/projscale/my-assets/blob/main/ton-connect.png?raw=true",
|
||||
})
|
||||
|
||||
|
||||
async def s_api_platform_metadata(request):
|
||||
return response.json({
|
||||
"name": "@MY",
|
||||
"image": "https://github.com/projscale/my-assets/blob/main/ton-connect.png?raw=true"
|
||||
})
|
||||
@@ -1,708 +0,0 @@
|
||||
"""
|
||||
Storage management routes with chunked uploads, download handling, and file operations.
|
||||
Provides secure file operations with progress tracking and comprehensive validation.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
from uuid import UUID
|
||||
|
||||
from sanic import Blueprint, Request, response
|
||||
from sanic.response import JSONResponse, ResponseStream
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import db_manager, get_cache_manager
|
||||
from app.core.logging import get_logger
|
||||
from app.core.storage import StorageManager
|
||||
from app.core.security import validate_file_signature, generate_secure_filename
|
||||
from app.api.middleware import require_auth, validate_request, rate_limit
|
||||
from app.core.validation import StorageUploadSchema, ChunkUploadSchema
|
||||
|
||||
# Initialize blueprint
|
||||
storage_bp = Blueprint("storage", url_prefix="/api/v1/storage")
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@storage_bp.route("/upload", methods=["POST"])
|
||||
@rate_limit(limit=10, window=3600) # 10 upload sessions per hour
|
||||
@require_auth(permissions=["storage.upload"])
|
||||
@validate_request(StorageUploadSchema)
|
||||
async def initiate_upload(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Initiate chunked file upload session with security validation.
|
||||
|
||||
Args:
|
||||
request: Sanic request with upload parameters
|
||||
|
||||
Returns:
|
||||
JSONResponse: Upload session information
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
# Validate file size against user quota
|
||||
cache_manager = get_cache_manager()
|
||||
quota_key = f"user:{user_id}:storage_quota"
|
||||
current_usage = await cache_manager.get(quota_key, default=0)
|
||||
|
||||
if current_usage + data["file_size"] > settings.MAX_STORAGE_PER_USER:
|
||||
return response.json(
|
||||
{"error": "Storage quota exceeded", "code": "QUOTA_EXCEEDED"},
|
||||
status=429
|
||||
)
|
||||
|
||||
# Generate secure filename
|
||||
secure_filename = generate_secure_filename(data["filename"], user_id)
|
||||
|
||||
# Validate content type
|
||||
allowed_types = {
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/avi',
|
||||
'audio/mpeg', 'audio/wav', 'audio/flac', 'audio/ogg',
|
||||
'application/pdf', 'text/plain', 'application/json',
|
||||
'application/zip', 'application/x-rar'
|
||||
}
|
||||
|
||||
if data["content_type"] not in allowed_types:
|
||||
return response.json(
|
||||
{"error": "File type not allowed", "code": "TYPE_NOT_ALLOWED"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Create content record first
|
||||
async with db_manager.get_session() as session:
|
||||
from app.core.models.content_models import Content
|
||||
|
||||
content = Content(
|
||||
user_id=user_id,
|
||||
title=secure_filename,
|
||||
content_type=data["content_type"],
|
||||
file_size=data["file_size"],
|
||||
status="uploading",
|
||||
visibility="private"
|
||||
)
|
||||
|
||||
session.add(content)
|
||||
await session.commit()
|
||||
await session.refresh(content)
|
||||
|
||||
# Create upload session
|
||||
storage_manager = StorageManager()
|
||||
upload_session = await storage_manager.create_upload_session(
|
||||
content.id,
|
||||
data["file_size"]
|
||||
)
|
||||
|
||||
# Update user quota
|
||||
await cache_manager.increment(quota_key, data["file_size"], ttl=86400)
|
||||
|
||||
await logger.ainfo(
|
||||
"Upload session initiated",
|
||||
user_id=str(user_id),
|
||||
content_id=str(content.id),
|
||||
filename=secure_filename,
|
||||
file_size=data["file_size"]
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"upload_session": upload_session,
|
||||
"content_id": str(content.id),
|
||||
"secure_filename": secure_filename,
|
||||
"status": "ready_for_upload"
|
||||
}, status=201)
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to initiate upload",
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to initiate upload", "code": "UPLOAD_INIT_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@storage_bp.route("/upload/<upload_id:uuid>/chunk", methods=["POST"])
|
||||
@rate_limit(limit=1000, window=3600) # 1000 chunks per hour
|
||||
@require_auth(permissions=["storage.upload"])
|
||||
async def upload_chunk(request: Request, upload_id: UUID) -> JSONResponse:
|
||||
"""
|
||||
Upload individual file chunk with validation and progress tracking.
|
||||
|
||||
Args:
|
||||
request: Sanic request with chunk data
|
||||
upload_id: Upload session UUID
|
||||
|
||||
Returns:
|
||||
JSONResponse: Chunk upload status
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
# Get chunk data from form
|
||||
if 'chunk' not in request.files:
|
||||
return response.json(
|
||||
{"error": "No chunk data provided", "code": "NO_CHUNK_DATA"},
|
||||
status=400
|
||||
)
|
||||
|
||||
chunk_file = request.files['chunk'][0]
|
||||
chunk_data = chunk_file.body
|
||||
|
||||
# Get chunk metadata
|
||||
chunk_index = int(request.form.get('chunk_index', 0))
|
||||
chunk_hash = request.form.get('chunk_hash', '')
|
||||
is_final = request.form.get('is_final', 'false').lower() == 'true'
|
||||
|
||||
if not chunk_hash:
|
||||
return response.json(
|
||||
{"error": "Chunk hash required", "code": "HASH_REQUIRED"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Validate chunk size
|
||||
if len(chunk_data) > settings.MAX_CHUNK_SIZE:
|
||||
return response.json(
|
||||
{"error": "Chunk too large", "code": "CHUNK_TOO_LARGE"},
|
||||
status=400
|
||||
)
|
||||
|
||||
# Upload chunk
|
||||
storage_manager = StorageManager()
|
||||
result = await storage_manager.upload_chunk(
|
||||
upload_id,
|
||||
chunk_index,
|
||||
chunk_data,
|
||||
chunk_hash
|
||||
)
|
||||
|
||||
# Check if upload is complete
|
||||
if is_final or result["uploaded_chunks"] == result["total_chunks"]:
|
||||
# Finalize upload
|
||||
finalize_result = await storage_manager.finalize_upload(upload_id)
|
||||
result.update(finalize_result)
|
||||
|
||||
await logger.ainfo(
|
||||
"Upload completed",
|
||||
upload_id=str(upload_id),
|
||||
user_id=str(user_id),
|
||||
content_id=finalize_result.get("content_id")
|
||||
)
|
||||
|
||||
return response.json(result)
|
||||
|
||||
except ValueError as e:
|
||||
await logger.awarning(
|
||||
"Chunk upload validation failed",
|
||||
upload_id=str(upload_id),
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": str(e), "code": "VALIDATION_FAILED"},
|
||||
status=400
|
||||
)
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Chunk upload failed",
|
||||
upload_id=str(upload_id),
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Chunk upload failed", "code": "CHUNK_UPLOAD_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@storage_bp.route("/upload/<upload_id:uuid>/status", methods=["GET"])
|
||||
@rate_limit(limit=100, window=3600) # 100 status checks per hour
|
||||
@require_auth(permissions=["storage.upload"])
|
||||
async def get_upload_status(request: Request, upload_id: UUID) -> JSONResponse:
|
||||
"""
|
||||
Get upload session status and progress.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
upload_id: Upload session UUID
|
||||
|
||||
Returns:
|
||||
JSONResponse: Upload progress information
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
storage_manager = StorageManager()
|
||||
|
||||
# Get session data
|
||||
session_data = await storage_manager._get_upload_session(upload_id)
|
||||
|
||||
if not session_data:
|
||||
return response.json(
|
||||
{"error": "Upload session not found", "code": "SESSION_NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
# Verify user ownership
|
||||
async with db_manager.get_session() as session:
|
||||
from app.core.models.content_models import Content
|
||||
|
||||
stmt = select(Content).where(
|
||||
Content.id == UUID(session_data["content_id"])
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
content = result.scalar_one_or_none()
|
||||
|
||||
if not content or content.user_id != user_id:
|
||||
return response.json(
|
||||
{"error": "Access denied", "code": "ACCESS_DENIED"},
|
||||
status=403
|
||||
)
|
||||
|
||||
# Calculate progress
|
||||
uploaded_chunks = len(session_data.get("uploaded_chunks", []))
|
||||
total_chunks = session_data["total_chunks"]
|
||||
progress_percent = (uploaded_chunks / total_chunks * 100) if total_chunks > 0 else 0
|
||||
|
||||
return response.json({
|
||||
"upload_id": str(upload_id),
|
||||
"status": session_data["status"],
|
||||
"progress": {
|
||||
"uploaded_chunks": uploaded_chunks,
|
||||
"total_chunks": total_chunks,
|
||||
"percent": round(progress_percent, 2)
|
||||
},
|
||||
"created_at": session_data["created_at"],
|
||||
"expires_at": session_data["expires_at"]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to get upload status",
|
||||
upload_id=str(upload_id),
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to get upload status", "code": "STATUS_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@storage_bp.route("/upload/<upload_id:uuid>", methods=["DELETE"])
|
||||
@rate_limit(limit=50, window=3600) # 50 cancellations per hour
|
||||
@require_auth(permissions=["storage.upload"])
|
||||
async def cancel_upload(request: Request, upload_id: UUID) -> JSONResponse:
|
||||
"""
|
||||
Cancel upload session and clean up temporary files.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
upload_id: Upload session UUID
|
||||
|
||||
Returns:
|
||||
JSONResponse: Cancellation status
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
storage_manager = StorageManager()
|
||||
|
||||
# Get session data
|
||||
session_data = await storage_manager._get_upload_session(upload_id)
|
||||
|
||||
if not session_data:
|
||||
return response.json(
|
||||
{"error": "Upload session not found", "code": "SESSION_NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
# Verify user ownership
|
||||
content_id = UUID(session_data["content_id"])
|
||||
async with db_manager.get_session() as session:
|
||||
from app.core.models.content_models import Content
|
||||
|
||||
stmt = select(Content).where(Content.id == content_id)
|
||||
result = await session.execute(stmt)
|
||||
content = result.scalar_one_or_none()
|
||||
|
||||
if not content or content.user_id != user_id:
|
||||
return response.json(
|
||||
{"error": "Access denied", "code": "ACCESS_DENIED"},
|
||||
status=403
|
||||
)
|
||||
|
||||
# Delete content record
|
||||
await session.delete(content)
|
||||
await session.commit()
|
||||
|
||||
# Clean up chunks and session
|
||||
cache_manager = get_cache_manager()
|
||||
session_key = f"upload_session:{upload_id}"
|
||||
await cache_manager.delete(session_key)
|
||||
|
||||
# Clean up chunks from storage
|
||||
for chunk_index in session_data.get("uploaded_chunks", []):
|
||||
chunk_id = f"{upload_id}_{chunk_index:06d}"
|
||||
await storage_manager.backend.delete_chunk(chunk_id)
|
||||
|
||||
# Update user quota
|
||||
quota_key = f"user:{user_id}:storage_quota"
|
||||
await cache_manager.decrement(quota_key, session_data.get("total_size", 0))
|
||||
|
||||
await logger.ainfo(
|
||||
"Upload cancelled",
|
||||
upload_id=str(upload_id),
|
||||
user_id=str(user_id),
|
||||
content_id=str(content_id)
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"status": "cancelled",
|
||||
"upload_id": str(upload_id)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to cancel upload",
|
||||
upload_id=str(upload_id),
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to cancel upload", "code": "CANCEL_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@storage_bp.route("/files/<content_id:uuid>", methods=["DELETE"])
|
||||
@rate_limit(limit=50, window=3600) # 50 deletions per hour
|
||||
@require_auth(permissions=["storage.delete"])
|
||||
async def delete_file(request: Request, content_id: UUID) -> JSONResponse:
|
||||
"""
|
||||
Delete content file and cleanup storage.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
content_id: Content UUID to delete
|
||||
|
||||
Returns:
|
||||
JSONResponse: Deletion status
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
from app.core.models.content_models import Content
|
||||
|
||||
# Get content
|
||||
stmt = select(Content).where(Content.id == content_id)
|
||||
result = await session.execute(stmt)
|
||||
content = result.scalar_one_or_none()
|
||||
|
||||
if not content:
|
||||
return response.json(
|
||||
{"error": "Content not found", "code": "NOT_FOUND"},
|
||||
status=404
|
||||
)
|
||||
|
||||
# Check permissions
|
||||
if content.user_id != user_id and not request.ctx.user.is_admin:
|
||||
return response.json(
|
||||
{"error": "Access denied", "code": "ACCESS_DENIED"},
|
||||
status=403
|
||||
)
|
||||
|
||||
# Delete files
|
||||
storage_manager = StorageManager()
|
||||
deletion_success = await storage_manager.delete_content_files(content_id)
|
||||
|
||||
if not deletion_success:
|
||||
await logger.awarning(
|
||||
"File deletion partially failed",
|
||||
content_id=str(content_id),
|
||||
user_id=str(user_id)
|
||||
)
|
||||
|
||||
# Update user quota
|
||||
cache_manager = get_cache_manager()
|
||||
quota_key = f"user:{user_id}:storage_quota"
|
||||
await cache_manager.decrement(quota_key, content.file_size or 0)
|
||||
|
||||
# Clear caches
|
||||
await cache_manager.delete(f"content:{content_id}")
|
||||
await cache_manager.delete(f"content:{content_id}:full")
|
||||
|
||||
await logger.ainfo(
|
||||
"Content deleted",
|
||||
content_id=str(content_id),
|
||||
user_id=str(user_id),
|
||||
file_size=content.file_size
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"status": "deleted",
|
||||
"content_id": str(content_id)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to delete content",
|
||||
content_id=str(content_id),
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to delete content", "code": "DELETE_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@storage_bp.route("/quota", methods=["GET"])
|
||||
@rate_limit(limit=100, window=3600) # 100 quota checks per hour
|
||||
@require_auth(permissions=["storage.read"])
|
||||
async def get_storage_quota(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Get user storage quota and usage information.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: Quota information
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
# Get current usage from cache
|
||||
cache_manager = get_cache_manager()
|
||||
quota_key = f"user:{user_id}:storage_quota"
|
||||
current_usage = await cache_manager.get(quota_key, default=0)
|
||||
|
||||
# Calculate accurate usage from database
|
||||
async with db_manager.get_session() as session:
|
||||
from sqlalchemy import func
|
||||
from app.core.models.content_models import Content
|
||||
|
||||
stmt = select(
|
||||
func.count(Content.id).label('file_count'),
|
||||
func.sum(Content.file_size).label('total_size')
|
||||
).where(
|
||||
Content.user_id == user_id,
|
||||
Content.status == 'completed'
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
stats = result.first()
|
||||
|
||||
accurate_usage = stats.total_size or 0
|
||||
file_count = stats.file_count or 0
|
||||
|
||||
# Update cache with accurate value
|
||||
if abs(current_usage - accurate_usage) > 1024: # Update if difference > 1KB
|
||||
await cache_manager.set(quota_key, accurate_usage, ttl=86400)
|
||||
current_usage = accurate_usage
|
||||
|
||||
# Calculate quota information
|
||||
max_quota = settings.MAX_STORAGE_PER_USER
|
||||
usage_percent = (current_usage / max_quota * 100) if max_quota > 0 else 0
|
||||
|
||||
return response.json({
|
||||
"quota": {
|
||||
"used_bytes": current_usage,
|
||||
"max_bytes": max_quota,
|
||||
"available_bytes": max(0, max_quota - current_usage),
|
||||
"usage_percent": round(usage_percent, 2)
|
||||
},
|
||||
"files": {
|
||||
"count": file_count,
|
||||
"max_files": settings.MAX_FILES_PER_USER
|
||||
},
|
||||
"updated_at": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to get storage quota",
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to get quota information", "code": "QUOTA_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@storage_bp.route("/stats", methods=["GET"])
|
||||
@rate_limit(limit=50, window=3600) # 50 stats requests per hour
|
||||
@require_auth(permissions=["storage.read"])
|
||||
async def get_storage_stats(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Get detailed storage statistics for user.
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: Detailed storage statistics
|
||||
"""
|
||||
try:
|
||||
user_id = request.ctx.user.id
|
||||
|
||||
async with db_manager.get_session() as session:
|
||||
from sqlalchemy import func
|
||||
from app.core.models.content_models import Content
|
||||
|
||||
# Get statistics by content type
|
||||
type_stmt = select(
|
||||
Content.content_type,
|
||||
func.count(Content.id).label('count'),
|
||||
func.sum(Content.file_size).label('size'),
|
||||
func.avg(Content.file_size).label('avg_size')
|
||||
).where(
|
||||
Content.user_id == user_id,
|
||||
Content.status == 'completed'
|
||||
).group_by(Content.content_type)
|
||||
|
||||
type_result = await session.execute(type_stmt)
|
||||
type_stats = {
|
||||
row.content_type: {
|
||||
'count': row.count,
|
||||
'total_size': row.size or 0,
|
||||
'average_size': row.avg_size or 0
|
||||
}
|
||||
for row in type_result
|
||||
}
|
||||
|
||||
# Get upload statistics by month
|
||||
monthly_stmt = select(
|
||||
func.date_trunc('month', Content.created_at).label('month'),
|
||||
func.count(Content.id).label('uploads'),
|
||||
func.sum(Content.file_size).label('size')
|
||||
).where(
|
||||
Content.user_id == user_id,
|
||||
Content.status == 'completed',
|
||||
Content.created_at >= datetime.utcnow().replace(day=1) - timedelta(days=365)
|
||||
).group_by(func.date_trunc('month', Content.created_at))
|
||||
|
||||
monthly_result = await session.execute(monthly_stmt)
|
||||
monthly_stats = [
|
||||
{
|
||||
'month': row.month.isoformat(),
|
||||
'uploads': row.uploads,
|
||||
'size': row.size or 0
|
||||
}
|
||||
for row in monthly_result
|
||||
]
|
||||
|
||||
return response.json({
|
||||
"by_type": type_stats,
|
||||
"monthly": monthly_stats,
|
||||
"generated_at": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Failed to get storage stats",
|
||||
user_id=str(user_id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Failed to get storage statistics", "code": "STATS_FAILED"},
|
||||
status=500
|
||||
)
|
||||
|
||||
@storage_bp.route("/cleanup", methods=["POST"])
|
||||
@rate_limit(limit=5, window=3600) # 5 cleanup operations per hour
|
||||
@require_auth(permissions=["storage.admin"])
|
||||
async def cleanup_orphaned_files(request: Request) -> JSONResponse:
|
||||
"""
|
||||
Clean up orphaned files and incomplete uploads (admin only).
|
||||
|
||||
Args:
|
||||
request: Sanic request object
|
||||
|
||||
Returns:
|
||||
JSONResponse: Cleanup results
|
||||
"""
|
||||
try:
|
||||
if not request.ctx.user.is_admin:
|
||||
return response.json(
|
||||
{"error": "Admin access required", "code": "ADMIN_REQUIRED"},
|
||||
status=403
|
||||
)
|
||||
|
||||
storage_manager = StorageManager()
|
||||
cache_manager = get_cache_manager()
|
||||
|
||||
cleanup_stats = {
|
||||
"orphaned_chunks": 0,
|
||||
"expired_sessions": 0,
|
||||
"failed_uploads": 0,
|
||||
"freed_space": 0
|
||||
}
|
||||
|
||||
# Clean up expired upload sessions
|
||||
async with db_manager.get_session() as session:
|
||||
from app.core.models.storage import ContentUploadSession
|
||||
from app.core.models.content_models import Content
|
||||
|
||||
# Get expired sessions
|
||||
expired_sessions_stmt = select(ContentUploadSession).where(
|
||||
ContentUploadSession.expires_at < datetime.utcnow()
|
||||
)
|
||||
expired_result = await session.execute(expired_sessions_stmt)
|
||||
expired_sessions = expired_result.scalars().all()
|
||||
|
||||
for upload_session in expired_sessions:
|
||||
# Clean up chunks
|
||||
session_key = f"upload_session:{upload_session.id}"
|
||||
session_data = await cache_manager.get(session_key)
|
||||
|
||||
if session_data:
|
||||
for chunk_index in session_data.get("uploaded_chunks", []):
|
||||
chunk_id = f"{upload_session.id}_{chunk_index:06d}"
|
||||
if await storage_manager.backend.delete_chunk(chunk_id):
|
||||
cleanup_stats["orphaned_chunks"] += 1
|
||||
|
||||
# Delete session
|
||||
await session.delete(upload_session)
|
||||
await cache_manager.delete(session_key)
|
||||
cleanup_stats["expired_sessions"] += 1
|
||||
|
||||
# Clean up failed uploads (older than 24 hours)
|
||||
failed_uploads_stmt = select(Content).where(
|
||||
Content.status.in_(['uploading', 'processing', 'failed']),
|
||||
Content.created_at < datetime.utcnow() - timedelta(hours=24)
|
||||
)
|
||||
failed_result = await session.execute(failed_uploads_stmt)
|
||||
failed_uploads = failed_result.scalars().all()
|
||||
|
||||
for content in failed_uploads:
|
||||
if content.file_path:
|
||||
if await storage_manager.backend.delete_file(content.file_path):
|
||||
cleanup_stats["freed_space"] += content.file_size or 0
|
||||
|
||||
await session.delete(content)
|
||||
cleanup_stats["failed_uploads"] += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
await logger.ainfo(
|
||||
"Storage cleanup completed",
|
||||
**cleanup_stats,
|
||||
admin_user=str(request.ctx.user.id)
|
||||
)
|
||||
|
||||
return response.json({
|
||||
"status": "cleanup_completed",
|
||||
"results": cleanup_stats,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
await logger.aerror(
|
||||
"Storage cleanup failed",
|
||||
admin_user=str(request.ctx.user.id),
|
||||
error=str(e)
|
||||
)
|
||||
return response.json(
|
||||
{"error": "Cleanup operation failed", "code": "CLEANUP_FAILED"},
|
||||
status=500
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram.utils.web_app import safe_parse_webapp_init_data
|
||||
from sanic import response
|
||||
|
||||
from app.core._blockchain.ton.connect import TonConnect, unpack_wallet_info, WalletConnection
|
||||
from app.core._config import TELEGRAM_API_KEY
|
||||
from app.core.models.user import User
|
||||
from app.core.logger import make_log
|
||||
|
||||
|
||||
async def pause_ton_connection(ton_connect: TonConnect):
|
||||
if ton_connect.connected:
|
||||
ton_connect._sdk_client.pause_connection()
|
||||
|
||||
|
||||
async def s_api_v1_tonconnect_new(request):
|
||||
if not request.ctx.user:
|
||||
return response.json({"error": "User not found"}, status=400)
|
||||
|
||||
wallet_app_name = request.args.get("wallet_app_name", "tonkeeper")
|
||||
|
||||
db_session = request.ctx.db_session
|
||||
user = request.ctx.user
|
||||
memory = request.ctx.memory
|
||||
ton_connect, ton_connection = TonConnect.by_user(db_session, user)
|
||||
await ton_connect.restore_connection()
|
||||
make_log("TonConnect_API", f"SDK connected?: {ton_connect.connected}", level='info')
|
||||
if ton_connect.connected:
|
||||
return response.json({"error": "Already connected"}, status=400)
|
||||
|
||||
connection_link = await ton_connect.new_connection(wallet_app_name)
|
||||
ton_connect.connected
|
||||
memory.add_task(pause_ton_connection, ton_connect, delay_s=60 * 3)
|
||||
make_log("TonConnect_API", f"New connection link for {wallet_app_name}: {connection_link}", level='debug')
|
||||
return response.json({
|
||||
"connection_link": connection_link,
|
||||
"wallet_app_name": wallet_app_name
|
||||
})
|
||||
|
||||
|
||||
async def s_api_v1_tonconnect_logout(request):
|
||||
if not request.ctx.user:
|
||||
return response.json({"error": "User not found"}, status=400)
|
||||
|
||||
db_session = request.ctx.db_session
|
||||
user = request.ctx.user
|
||||
memory = request.ctx.memory
|
||||
|
||||
wallet_connections = db_session.query(WalletConnection).filter(
|
||||
WalletConnection.user_id == user.id,
|
||||
WalletConnection.invalidated == False
|
||||
).all()
|
||||
for wallet_connection in wallet_connections:
|
||||
wallet_connection.invalidated = True
|
||||
|
||||
db_session.commit()
|
||||
return response.json({"success": True})
|
||||
|
||||
Reference in new issue
Block a user