Compare commits
13
Commits
relayers
...
7d81e7aff3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d81e7aff3 | ||
|
|
e3b86d6b4e | ||
|
|
d1f04b8b5e | ||
|
|
721af9bc83 | ||
|
|
c170ca5433 | ||
|
|
8651efd578 | ||
|
|
3511ebd247 | ||
|
|
64491e19e1 | ||
|
|
3c84ec43a2 | ||
|
|
4ec91cb657 | ||
|
|
b7afa284aa | ||
|
|
e6153c881a | ||
|
|
38e54f0ab2 |
No files matched your search
+22
-8
@@ -33,12 +33,22 @@ from app.api.routes.content import s_api_v1_content_list, s_api_v1_content_view,
|
|||||||
from app.api.routes.content_index import s_api_v1_content_index, s_api_v1_content_delta
|
from app.api.routes.content_index import s_api_v1_content_index, s_api_v1_content_delta
|
||||||
from app.api.routes.derivatives import s_api_v1_content_derivatives
|
from app.api.routes.derivatives import s_api_v1_content_derivatives
|
||||||
from app.api.routes.admin import (
|
from app.api.routes.admin import (
|
||||||
|
s_api_v1_admin_blockchain,
|
||||||
|
s_api_v1_admin_cache_cleanup,
|
||||||
|
s_api_v1_admin_cache_setlimits,
|
||||||
|
s_api_v1_admin_licenses,
|
||||||
|
s_api_v1_admin_login,
|
||||||
|
s_api_v1_admin_logout,
|
||||||
s_api_v1_admin_node_setrole,
|
s_api_v1_admin_node_setrole,
|
||||||
s_api_v1_admin_nodes,
|
s_api_v1_admin_nodes,
|
||||||
|
s_api_v1_admin_overview,
|
||||||
|
s_api_v1_admin_stars,
|
||||||
s_api_v1_admin_status,
|
s_api_v1_admin_status,
|
||||||
s_api_v1_admin_cache_setlimits,
|
s_api_v1_admin_storage,
|
||||||
s_api_v1_admin_cache_cleanup,
|
|
||||||
s_api_v1_admin_sync_setlimits,
|
s_api_v1_admin_sync_setlimits,
|
||||||
|
s_api_v1_admin_system,
|
||||||
|
s_api_v1_admin_uploads,
|
||||||
|
s_api_v1_admin_users,
|
||||||
)
|
)
|
||||||
from app.api.routes.tonconnect import s_api_v1_tonconnect_new, s_api_v1_tonconnect_logout
|
from app.api.routes.tonconnect import s_api_v1_tonconnect_new, s_api_v1_tonconnect_logout
|
||||||
from app.api.routes.keys import s_api_v1_keys_request
|
from app.api.routes.keys import s_api_v1_keys_request
|
||||||
@@ -86,6 +96,16 @@ app.add_route(s_api_v1_5_content_list, "/api/v1.5/content.list", methods=["GET",
|
|||||||
app.add_route(s_api_v1_content_index, "/api/v1/content.index", methods=["GET", "OPTIONS"])
|
app.add_route(s_api_v1_content_index, "/api/v1/content.index", methods=["GET", "OPTIONS"])
|
||||||
app.add_route(s_api_v1_content_delta, "/api/v1/content.delta", methods=["GET", "OPTIONS"])
|
app.add_route(s_api_v1_content_delta, "/api/v1/content.delta", methods=["GET", "OPTIONS"])
|
||||||
app.add_route(s_api_v1_content_derivatives, "/api/v1/content.derivatives", methods=["GET", "OPTIONS"])
|
app.add_route(s_api_v1_content_derivatives, "/api/v1/content.derivatives", methods=["GET", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_login, "/api/v1/admin.login", methods=["POST", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_logout, "/api/v1/admin.logout", methods=["POST", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_overview, "/api/v1/admin.overview", methods=["GET", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_storage, "/api/v1/admin.storage", methods=["GET", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_uploads, "/api/v1/admin.uploads", methods=["GET", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_users, "/api/v1/admin.users", methods=["GET", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_licenses, "/api/v1/admin.licenses", methods=["GET", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_stars, "/api/v1/admin.stars", methods=["GET", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_system, "/api/v1/admin.system", methods=["GET", "OPTIONS"])
|
||||||
|
app.add_route(s_api_v1_admin_blockchain, "/api/v1/admin.blockchain", methods=["GET", "OPTIONS"])
|
||||||
app.add_route(s_api_v1_admin_node_setrole, "/api/v1/admin.node.setRole", methods=["POST", "OPTIONS"])
|
app.add_route(s_api_v1_admin_node_setrole, "/api/v1/admin.node.setRole", methods=["POST", "OPTIONS"])
|
||||||
app.add_route(s_api_v1_admin_nodes, "/api/v1/admin.nodes", methods=["GET", "OPTIONS"])
|
app.add_route(s_api_v1_admin_nodes, "/api/v1/admin.nodes", methods=["GET", "OPTIONS"])
|
||||||
app.add_route(s_api_v1_admin_status, "/api/v1/admin.status", methods=["GET", "OPTIONS"])
|
app.add_route(s_api_v1_admin_status, "/api/v1/admin.status", methods=["GET", "OPTIONS"])
|
||||||
@@ -156,10 +176,4 @@ async def s_handle_exception(request, exception):
|
|||||||
|
|
||||||
response_buffer = response.json(payload, status=status)
|
response_buffer = response.json(payload, status=status)
|
||||||
response_buffer = await close_db_session(request, response_buffer)
|
response_buffer = await close_db_session(request, response_buffer)
|
||||||
response_buffer.headers["Access-Control-Allow-Origin"] = "*"
|
|
||||||
response_buffer.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
||||||
response_buffer.headers["Access-Control-Allow-Headers"] = "Origin, Content-Type, Accept, Authorization, Referer, User-Agent, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site, x-request-id"
|
|
||||||
response_buffer.headers["Access-Control-Allow-Credentials"] = "true"
|
|
||||||
response_buffer.headers["X-Session-Id"] = session_id
|
|
||||||
response_buffer.headers["X-Error-Id"] = error_id
|
|
||||||
return response_buffer
|
return response_buffer
|
||||||
+22
-12
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from base58 import b58decode
|
from base58 import b58decode
|
||||||
from sanic import response as sanic_response
|
from sanic import response as sanic_response
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -17,20 +18,30 @@ from app.core.log_context import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ENABLE_INTERNAL_CORS = os.getenv("ENABLE_INTERNAL_CORS", "1").lower() in {"1", "true", "yes"}
|
||||||
|
|
||||||
|
|
||||||
def attach_headers(response, request=None):
|
def attach_headers(response, request=None):
|
||||||
|
response.headers.pop("Access-Control-Allow-Origin", None)
|
||||||
|
response.headers.pop("Access-Control-Allow-Methods", None)
|
||||||
|
response.headers.pop("Access-Control-Allow-Headers", None)
|
||||||
|
response.headers.pop("Access-Control-Allow-Credentials", None)
|
||||||
|
|
||||||
|
if not ENABLE_INTERNAL_CORS:
|
||||||
|
return response
|
||||||
|
|
||||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PATCH, HEAD"
|
||||||
response.headers["Access-Control-Allow-Headers"] = "Origin, Content-Type, Accept, Authorization, Referer, User-Agent, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site, x-file-name, x-last-chunk, x-chunk-start, x-upload-id, x-request-id"
|
response.headers["Access-Control-Allow-Headers"] = (
|
||||||
# response.headers["Access-Control-Allow-Credentials"] = "true"
|
"Origin, Content-Type, Accept, Authorization, Referer, User-Agent, Sec-Fetch-Dest, Sec-Fetch-Mode, "
|
||||||
try:
|
"Sec-Fetch-Site, Tus-Resumable, tus-resumable, Upload-Length, upload-length, Upload-Offset, upload-offset, "
|
||||||
sid = getattr(request.ctx, 'session_id', None) if request else None
|
"Upload-Metadata, upload-metadata, Upload-Defer-Length, upload-defer-length, Upload-Concat, upload-concat, "
|
||||||
if sid:
|
"x-file-name, x-last-chunk, x-chunk-start, x-upload-id, x-request-id"
|
||||||
response.headers["X-Session-Id"] = sid
|
)
|
||||||
except BaseException:
|
|
||||||
pass
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def try_authorization(request):
|
async def try_authorization(request):
|
||||||
token = request.headers.get("Authorization")
|
token = request.headers.get("Authorization")
|
||||||
if not token:
|
if not token:
|
||||||
@@ -200,6 +211,8 @@ async def close_request_handler(request, response):
|
|||||||
if request.method == 'OPTIONS':
|
if request.method == 'OPTIONS':
|
||||||
response = sanic_response.text("OK")
|
response = sanic_response.text("OK")
|
||||||
|
|
||||||
|
response = attach_headers(response, request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await request.ctx.db_session.close()
|
await request.ctx.db_session.close()
|
||||||
except BaseException:
|
except BaseException:
|
||||||
@@ -214,14 +227,11 @@ async def close_request_handler(request, response):
|
|||||||
except BaseException:
|
except BaseException:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
response = attach_headers(response, request)
|
|
||||||
|
|
||||||
return request, response
|
return request, response
|
||||||
|
|
||||||
|
|
||||||
async def close_db_session(request, response):
|
async def close_db_session(request, response):
|
||||||
request, response = await close_request_handler(request, response)
|
request, response = await close_request_handler(request, response)
|
||||||
response = attach_headers(response, request)
|
|
||||||
# Clear contextvars
|
# Clear contextvars
|
||||||
try:
|
try:
|
||||||
ctx_session_id.set(None)
|
ctx_session_id.set(None)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from sqlalchemy import and_, select, func
|
|||||||
from tonsdk.boc import begin_cell, begin_dict
|
from tonsdk.boc import begin_cell, begin_dict
|
||||||
from tonsdk.utils import Address
|
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.connect import TonConnect, wallet_obj_by_name
|
||||||
from app.core._blockchain.ton.platform import platform
|
from app.core._blockchain.ton.platform import platform
|
||||||
from app.core._config import PROJECT_HOST
|
from app.core._config import PROJECT_HOST
|
||||||
@@ -58,26 +57,21 @@ async def s_api_v1_blockchain_send_new_content_message(request):
|
|||||||
assert field_value(request.json[field_key]), f"Invalid {field_key} provided"
|
assert field_value(request.json[field_key]), f"Invalid {field_key} provided"
|
||||||
|
|
||||||
# Support legacy: 'content' as decrypted ContentId; and new: 'content' as encrypted IPFS CID
|
# Support legacy: 'content' as decrypted ContentId; and new: 'content' as encrypted IPFS CID
|
||||||
|
source_content_cid, cid_err = resolve_content(request.json['content'])
|
||||||
|
assert not cid_err, f"Invalid content CID provided: {cid_err}"
|
||||||
|
|
||||||
encrypted_content_cid = None
|
encrypted_content_cid = None
|
||||||
try:
|
decrypted_content = (await request.ctx.db_session.execute(
|
||||||
# Legacy path
|
select(StoredContent).where(StoredContent.hash == source_content_cid.content_hash_b58)
|
||||||
decrypted_content_cid, err = resolve_content(request.json['content'])
|
)).scalars().first()
|
||||||
assert not err
|
|
||||||
decrypted_content = (await request.ctx.db_session.execute(
|
if decrypted_content and decrypted_content.type == "local/content_bin":
|
||||||
select(StoredContent).where(StoredContent.hash == decrypted_content_cid.content_hash_b58)
|
|
||||||
)).scalars().first()
|
|
||||||
assert decrypted_content and decrypted_content.type == "local/content_bin"
|
|
||||||
encrypted_content = await create_encrypted_content(request.ctx.db_session, decrypted_content)
|
encrypted_content = await create_encrypted_content(request.ctx.db_session, decrypted_content)
|
||||||
encrypted_content_cid = encrypted_content.cid
|
encrypted_content_cid = encrypted_content.cid
|
||||||
except BaseException:
|
elif source_content_cid.cid_format == 'ipfs':
|
||||||
# New path: treat provided string as encrypted IPFS CID (ENCF v1)
|
encrypted_content_cid = source_content_cid
|
||||||
encrypted_ipfs_cid = request.json['content']
|
else:
|
||||||
class _EC: # tiny adapter to mimic .serialize_v2()
|
raise AssertionError("Provided content is neither locally available nor a valid encrypted CID")
|
||||||
def __init__(self, s: str):
|
|
||||||
self._s = s
|
|
||||||
def serialize_v2(self, include_accept_type: bool = False):
|
|
||||||
return self._s
|
|
||||||
encrypted_content_cid = _EC(encrypted_ipfs_cid)
|
|
||||||
|
|
||||||
if request.json['image']:
|
if request.json['image']:
|
||||||
image_content_cid, err = resolve_content(request.json['image'])
|
image_content_cid, err = resolve_content(request.json['image'])
|
||||||
@@ -152,7 +146,7 @@ async def s_api_v1_blockchain_send_new_content_message(request):
|
|||||||
user_id = str(request.ctx.user.id),
|
user_id = str(request.ctx.user.id),
|
||||||
user_internal_id=request.ctx.user.id,
|
user_internal_id=request.ctx.user.id,
|
||||||
action_type='freeUpload',
|
action_type='freeUpload',
|
||||||
action_ref=str(encrypted_content_cid.content_hash),
|
action_ref=encrypted_content_cid.serialize_v2(),
|
||||||
created=datetime.now()
|
created=datetime.now()
|
||||||
)
|
)
|
||||||
request.ctx.db_session.add(promo_action)
|
request.ctx.db_session.add(promo_action)
|
||||||
@@ -209,7 +203,7 @@ async def s_api_v1_blockchain_send_new_content_message(request):
|
|||||||
title=content_title,
|
title=content_title,
|
||||||
free_count=(promo_free_upload_available - 1)
|
free_count=(promo_free_upload_available - 1)
|
||||||
), message_type='hint', message_meta={
|
), message_type='hint', message_meta={
|
||||||
'encrypted_content_hash': b58encode(encrypted_content_cid.content_hash).decode(),
|
'encrypted_content_hash': encrypted_content_cid.content_hash_b58,
|
||||||
'hint_type': 'uploadContentTxRequested'
|
'hint_type': 'uploadContentTxRequested'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -219,54 +213,59 @@ async def s_api_v1_blockchain_send_new_content_message(request):
|
|||||||
'payload': ""
|
'payload': ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
user_wallet_address = await request.ctx.user.wallet_address_async(request.ctx.db_session)
|
||||||
|
assert user_wallet_address, "Wallet address is not linked"
|
||||||
|
|
||||||
await request.ctx.user_uploader_wrapper.send_message(
|
await request.ctx.user_uploader_wrapper.send_message(
|
||||||
request.ctx.user.translated('p_uploadContentTxRequested').format(
|
request.ctx.user.translated('p_uploadContentTxRequested').format(
|
||||||
title=content_title,
|
title=content_title,
|
||||||
), message_type='hint', message_meta={
|
), message_type='hint', message_meta={
|
||||||
'encrypted_content_hash': b58encode(encrypted_content_cid.content_hash).decode(),
|
'encrypted_content_hash': encrypted_content_cid.content_hash_b58,
|
||||||
'hint_type': 'uploadContentTxRequested'
|
'hint_type': 'uploadContentTxRequested'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
payload_cell = (
|
||||||
|
begin_cell()
|
||||||
|
.store_uint(0x5491d08c, 32)
|
||||||
|
.store_uint(int.from_bytes(encrypted_content_cid.content_hash, "big", signed=False), 256)
|
||||||
|
.store_address(Address(user_wallet_address))
|
||||||
|
.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()
|
||||||
|
)
|
||||||
|
|
||||||
return response.json({
|
return response.json({
|
||||||
'address': platform.address.to_string(1, 1, 1),
|
'address': platform.address.to_string(1, 1, 1),
|
||||||
'amount': str(int(0.03 * 10 ** 9)),
|
'amount': str(int(0.03 * 10 ** 9)),
|
||||||
'payload': b64encode(
|
'payload': b64encode(payload_cell.to_boc(False)).decode()
|
||||||
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:
|
except BaseException as e:
|
||||||
make_log("Blockchain", f"Error while sending new content message: {e}" + '\n' + traceback.format_exc(), level='error')
|
make_log("Blockchain", f"Error while sending new content message: {e}" + '\n' + traceback.format_exc(), level='error')
|
||||||
@@ -290,14 +289,15 @@ async def s_api_v1_blockchain_send_purchase_content_message(request):
|
|||||||
license_exist = (await request.ctx.db_session.execute(select(UserContent).where(
|
license_exist = (await request.ctx.db_session.execute(select(UserContent).where(
|
||||||
UserContent.onchain_address == request.json['content_address']
|
UserContent.onchain_address == request.json['content_address']
|
||||||
))).scalars().first()
|
))).scalars().first()
|
||||||
if license_exist:
|
from app.core.content.content_id import ContentId
|
||||||
from app.core.content.content_id import ContentId
|
|
||||||
_cid = ContentId.deserialize(license_exist.content.cid.serialize_v2())
|
if license_exist and license_exist.content_id:
|
||||||
r_content = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == _cid.content_hash_b58))).scalars().first()
|
r_content = (await request.ctx.db_session.execute(select(StoredContent).where(
|
||||||
|
StoredContent.id == license_exist.content_id
|
||||||
|
))).scalars().first()
|
||||||
else:
|
else:
|
||||||
from app.core.content.content_id import ContentId
|
requested_cid = ContentId.deserialize(request.json['content_address'])
|
||||||
_cid = ContentId.deserialize(request.json['content_address'])
|
r_content = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == requested_cid.content_hash_b58))).scalars().first()
|
||||||
r_content = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == _cid.content_hash_b58))).scalars().first()
|
|
||||||
|
|
||||||
async def open_content_async(session, sc: StoredContent):
|
async def open_content_async(session, sc: StoredContent):
|
||||||
if not sc.encrypted:
|
if not sc.encrypted:
|
||||||
|
|||||||
+1736
-50
File diff suppressed because it is too large.
Load diff
@@ -61,6 +61,21 @@ async def s_api_v1_auth_twa(request):
|
|||||||
)).scalars().first()
|
)).scalars().first()
|
||||||
assert known_user, "User not created"
|
assert known_user, "User not created"
|
||||||
|
|
||||||
|
meta_updated = False
|
||||||
|
if not (known_user.meta or {}).get('ref_id'):
|
||||||
|
known_user.ensure_ref_id()
|
||||||
|
meta_updated = True
|
||||||
|
|
||||||
|
incoming_ref_id = auth_data.get('ref_id')
|
||||||
|
stored_ref_id = (known_user.meta or {}).get('ref_id')
|
||||||
|
if incoming_ref_id and incoming_ref_id != stored_ref_id:
|
||||||
|
if (known_user.meta or {}).get('referrer_id') != incoming_ref_id:
|
||||||
|
known_user.meta = {
|
||||||
|
**(known_user.meta or {}),
|
||||||
|
'referrer_id': incoming_ref_id
|
||||||
|
}
|
||||||
|
meta_updated = True
|
||||||
|
|
||||||
new_user_key = await known_user.create_api_token_v1(request.ctx.db_session, "USER_API_V1")
|
new_user_key = await known_user.create_api_token_v1(request.ctx.db_session, "USER_API_V1")
|
||||||
if auth_data['ton_proof']:
|
if auth_data['ton_proof']:
|
||||||
try:
|
try:
|
||||||
@@ -116,6 +131,8 @@ async def s_api_v1_auth_twa(request):
|
|||||||
)
|
)
|
||||||
).order_by(WalletConnection.created.desc()))).scalars().first()
|
).order_by(WalletConnection.created.desc()))).scalars().first()
|
||||||
known_user.last_use = datetime.now()
|
known_user.last_use = datetime.now()
|
||||||
|
if meta_updated:
|
||||||
|
known_user.updated = datetime.now()
|
||||||
await request.ctx.db_session.commit()
|
await request.ctx.db_session.commit()
|
||||||
|
|
||||||
return response.json({
|
return response.json({
|
||||||
|
|||||||
+162
-42
@@ -10,7 +10,8 @@ from app.core.models.keys import KnownKey
|
|||||||
from app.core.models import StarsInvoice
|
from app.core.models import StarsInvoice
|
||||||
from app.core.models.content.user_content import UserContent
|
from app.core.models.content.user_content import UserContent
|
||||||
from app.core._config import CLIENT_TELEGRAM_API_KEY, PROJECT_HOST
|
from app.core._config import CLIENT_TELEGRAM_API_KEY, PROJECT_HOST
|
||||||
from app.core.models.content_v3 import EncryptedContent as ECv3, ContentDerivative as CDv3
|
from app.core.models.content_v3 import EncryptedContent as ECv3, ContentDerivative as CDv3, UploadSession
|
||||||
|
from app.core.content.content_id import ContentId
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ async def s_api_v1_content_list(request):
|
|||||||
select(StoredContent)
|
select(StoredContent)
|
||||||
.where(
|
.where(
|
||||||
StoredContent.type.like(store + '%'),
|
StoredContent.type.like(store + '%'),
|
||||||
StoredContent.disabled == False
|
StoredContent.disabled.is_(None)
|
||||||
)
|
)
|
||||||
.order_by(StoredContent.created.desc())
|
.order_by(StoredContent.created.desc())
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
@@ -50,8 +51,15 @@ async def s_api_v1_content_view(request, content_address: str):
|
|||||||
license_exist = (await request.ctx.db_session.execute(
|
license_exist = (await request.ctx.db_session.execute(
|
||||||
select(UserContent).where(UserContent.onchain_address == content_address)
|
select(UserContent).where(UserContent.onchain_address == content_address)
|
||||||
)).scalars().first()
|
)).scalars().first()
|
||||||
|
license_address = None
|
||||||
if license_exist:
|
if license_exist:
|
||||||
content_address = license_exist.content.cid.serialize_v2()
|
license_address = license_exist.onchain_address
|
||||||
|
if license_exist.content_id:
|
||||||
|
linked_content = (await request.ctx.db_session.execute(
|
||||||
|
select(StoredContent).where(StoredContent.id == license_exist.content_id)
|
||||||
|
)).scalars().first()
|
||||||
|
if linked_content:
|
||||||
|
content_address = linked_content.cid.serialize_v2()
|
||||||
|
|
||||||
from app.core.content.content_id import ContentId
|
from app.core.content.content_id import ContentId
|
||||||
cid = ContentId.deserialize(content_address)
|
cid = ContentId.deserialize(content_address)
|
||||||
@@ -74,9 +82,12 @@ async def s_api_v1_content_view(request, content_address: str):
|
|||||||
return {'encrypted_content': encrypted, 'decrypted_content': decrypted, 'content_type': content_type}
|
return {'encrypted_content': encrypted, 'decrypted_content': decrypted, 'content_type': content_type}
|
||||||
content = await open_content_async(request.ctx.db_session, r_content)
|
content = await open_content_async(request.ctx.db_session, r_content)
|
||||||
|
|
||||||
|
master_address = content['encrypted_content'].meta.get('item_address', '')
|
||||||
opts = {
|
opts = {
|
||||||
'content_type': content['content_type'], # возможно с ошибками, нужно переделать на ffprobe
|
'content_type': content['content_type'], # возможно с ошибками, нужно переделать на ffprobe
|
||||||
'content_address': content['encrypted_content'].meta.get('item_address', '')
|
'content_address': license_address or master_address,
|
||||||
|
'license_address': license_address,
|
||||||
|
'master_address': master_address,
|
||||||
}
|
}
|
||||||
if content['encrypted_content'].key_id:
|
if content['encrypted_content'].key_id:
|
||||||
known_key = (await request.ctx.db_session.execute(
|
known_key = (await request.ctx.db_session.execute(
|
||||||
@@ -157,59 +168,168 @@ async def s_api_v1_content_view(request, content_address: str):
|
|||||||
'amount': stars_cost,
|
'amount': stars_cost,
|
||||||
}
|
}
|
||||||
|
|
||||||
display_options = {
|
display_options = {'content_url': None}
|
||||||
'content_url': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if have_access:
|
if have_access:
|
||||||
opts['have_licenses'].append('listen')
|
opts['have_licenses'].append('listen')
|
||||||
|
|
||||||
converted_content = content['encrypted_content'].meta.get('converted_content')
|
enc_cid = content['encrypted_content'].meta.get('content_cid') or content['encrypted_content'].meta.get('encrypted_cid')
|
||||||
if converted_content:
|
ec_v3 = None
|
||||||
user_content_option = 'low_preview'
|
derivative_rows = []
|
||||||
if have_access:
|
if enc_cid:
|
||||||
user_content_option = 'low'
|
ec_v3 = (await request.ctx.db_session.execute(select(ECv3).where(ECv3.encrypted_cid == enc_cid))).scalars().first()
|
||||||
|
if ec_v3:
|
||||||
|
derivative_rows = (await request.ctx.db_session.execute(select(CDv3).where(CDv3.content_id == ec_v3.id))).scalars().all()
|
||||||
|
|
||||||
converted_content = (await request.ctx.db_session.execute(select(StoredContent).where(
|
upload_row = None
|
||||||
StoredContent.hash == converted_content[user_content_option]
|
if enc_cid:
|
||||||
))).scalars().first()
|
upload_row = (await request.ctx.db_session.execute(select(UploadSession).where(UploadSession.encrypted_cid == enc_cid))).scalars().first()
|
||||||
if converted_content:
|
|
||||||
display_options['content_url'] = converted_content.web_url
|
converted_meta_map = dict(content['encrypted_content'].meta.get('converted_content') or {})
|
||||||
opts['content_ext'] = converted_content.filename.split('.')[-1]
|
|
||||||
|
derivative_latest = {}
|
||||||
|
if derivative_rows:
|
||||||
|
derivative_sorted = sorted(derivative_rows, key=lambda row: row.created_at or datetime.min)
|
||||||
|
for row in derivative_sorted:
|
||||||
|
derivative_latest[row.kind] = row
|
||||||
|
|
||||||
|
def _row_to_hash_and_url(row):
|
||||||
|
if not row or not row.local_path:
|
||||||
|
return None, None
|
||||||
|
file_hash = row.local_path.split('/')[-1]
|
||||||
|
return file_hash, f"{PROJECT_HOST}/api/v1.5/storage/{file_hash}"
|
||||||
|
|
||||||
|
chosen_row = None
|
||||||
|
if have_access:
|
||||||
|
for key in ('decrypted_low', 'decrypted_high'):
|
||||||
|
if key in derivative_latest:
|
||||||
|
chosen_row = derivative_latest[key]
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
# v3 fallback: use derivatives table linked via encrypted_cid from onchain meta
|
for key in ('decrypted_preview', 'decrypted_low'):
|
||||||
enc_cid = content['encrypted_content'].meta.get('content_cid') or content['encrypted_content'].meta.get('encrypted_cid')
|
if key in derivative_latest:
|
||||||
if enc_cid:
|
chosen_row = derivative_latest[key]
|
||||||
ec = (await request.ctx.db_session.execute(select(ECv3).where(ECv3.encrypted_cid == enc_cid))).scalars().first()
|
break
|
||||||
if ec:
|
|
||||||
# choose preview for non-access; low for access
|
|
||||||
desired = ['decrypted_preview'] if not have_access else ['decrypted_low', 'decrypted_high']
|
|
||||||
rows = (await request.ctx.db_session.execute(select(CDv3).where(CDv3.content_id == ec.id, CDv3.status == 'ready'))).scalars().all()
|
|
||||||
chosen = None
|
|
||||||
for kind in desired:
|
|
||||||
chosen = next((r for r in rows if r.kind == kind), None)
|
|
||||||
if chosen:
|
|
||||||
break
|
|
||||||
if chosen and chosen.local_path:
|
|
||||||
h = chosen.local_path.split('/')[-1]
|
|
||||||
display_options['content_url'] = f"{PROJECT_HOST}/api/v1.5/storage/{h}"
|
|
||||||
opts['content_ext'] = (chosen.content_type or '').split('/')[-1] if chosen.content_type else None
|
|
||||||
|
|
||||||
|
if chosen_row:
|
||||||
|
file_hash, url = _row_to_hash_and_url(chosen_row)
|
||||||
|
if url:
|
||||||
|
display_options['content_url'] = url
|
||||||
|
opts['content_ext'] = (chosen_row.content_type or '').split('/')[-1] if chosen_row.content_type else None
|
||||||
|
converted_meta_map.setdefault('low' if have_access else 'low_preview', file_hash)
|
||||||
|
|
||||||
|
if not display_options['content_url'] and converted_meta_map:
|
||||||
|
preference = ['low', 'high', 'low_preview'] if have_access else ['low_preview', 'low', 'high']
|
||||||
|
for key in preference:
|
||||||
|
hash_value = converted_meta_map.get(key)
|
||||||
|
if not hash_value:
|
||||||
|
continue
|
||||||
|
stored = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == hash_value))).scalars().first()
|
||||||
|
if stored:
|
||||||
|
display_options['content_url'] = stored.web_url
|
||||||
|
opts['content_ext'] = stored.filename.split('.')[-1]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Metadata fallback
|
||||||
content_meta = content['encrypted_content'].json_format()
|
content_meta = content['encrypted_content'].json_format()
|
||||||
from app.core.content.content_id import ContentId
|
content_metadata_json = None
|
||||||
_mcid = content_meta.get('metadata_cid') or None
|
_mcid = content_meta.get('metadata_cid') or None
|
||||||
content_metadata = None
|
|
||||||
if _mcid:
|
if _mcid:
|
||||||
_cid = ContentId.deserialize(_mcid)
|
_cid = ContentId.deserialize(_mcid)
|
||||||
content_metadata = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == _cid.content_hash_b58))).scalars().first()
|
content_metadata = (await request.ctx.db_session.execute(select(StoredContent).where(StoredContent.hash == _cid.content_hash_b58))).scalars().first()
|
||||||
with open(content_metadata.filepath, 'r') as f:
|
if content_metadata:
|
||||||
content_metadata_json = json.loads(f.read())
|
try:
|
||||||
|
with open(content_metadata.filepath, 'r') as f:
|
||||||
|
content_metadata_json = json.loads(f.read())
|
||||||
|
except Exception as exc:
|
||||||
|
make_log("Content", f"Can't read metadata file: {exc}", level='warning')
|
||||||
|
|
||||||
|
if not content_metadata_json:
|
||||||
|
fallback_name = (ec_v3.title if ec_v3 else None) or content_meta.get('title') or content_meta.get('cid')
|
||||||
|
fallback_description = (ec_v3.description if ec_v3 else '') or ''
|
||||||
|
content_metadata_json = {
|
||||||
|
'name': fallback_name or 'Без названия',
|
||||||
|
'description': fallback_description,
|
||||||
|
'downloadable': False,
|
||||||
|
}
|
||||||
|
cover_cid = content_meta.get('cover_cid')
|
||||||
|
if cover_cid:
|
||||||
|
content_metadata_json.setdefault('image', f"{PROJECT_HOST}/api/v1.5/storage/{cover_cid}")
|
||||||
|
|
||||||
display_options['metadata'] = content_metadata_json
|
display_options['metadata'] = content_metadata_json
|
||||||
|
|
||||||
opts['downloadable'] = content_metadata_json.get('downloadable', False)
|
opts['downloadable'] = content_metadata_json.get('downloadable', False)
|
||||||
if opts['downloadable']:
|
if opts['downloadable'] and 'listen' not in opts['have_licenses']:
|
||||||
if not ('listen' in opts['have_licenses']):
|
opts['downloadable'] = False
|
||||||
opts['downloadable'] = False
|
|
||||||
|
# Conversion status summary
|
||||||
|
conversion_summary = {}
|
||||||
|
conversion_details = []
|
||||||
|
derivative_summary_map = {}
|
||||||
|
for row in derivative_latest.values():
|
||||||
|
conversion_summary[row.status] = conversion_summary.get(row.status, 0) + 1
|
||||||
|
derivative_summary_map[row.kind] = row
|
||||||
|
conversion_details.append({
|
||||||
|
'kind': row.kind,
|
||||||
|
'status': row.status,
|
||||||
|
'size_bytes': row.size_bytes,
|
||||||
|
'content_type': row.content_type,
|
||||||
|
'error': row.error,
|
||||||
|
'updated_at': (row.last_access_at or row.created_at).isoformat() + 'Z' if (row.last_access_at or row.created_at) else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
required_kinds = {'decrypted_low', 'decrypted_high'}
|
||||||
|
if ec_v3 and ec_v3.content_type.startswith('video/'):
|
||||||
|
required_kinds.add('decrypted_preview')
|
||||||
|
|
||||||
|
statuses_by_kind = {kind: row.status for kind, row in derivative_summary_map.items() if kind in required_kinds}
|
||||||
|
conversion_state = 'pending'
|
||||||
|
if required_kinds and all(statuses_by_kind.get(kind) == 'ready' for kind in required_kinds):
|
||||||
|
conversion_state = 'ready'
|
||||||
|
elif any(statuses_by_kind.get(kind) == 'failed' for kind in required_kinds):
|
||||||
|
conversion_state = 'failed'
|
||||||
|
elif any(statuses_by_kind.get(kind) in ('processing', 'pending') for kind in required_kinds):
|
||||||
|
conversion_state = 'processing'
|
||||||
|
elif statuses_by_kind:
|
||||||
|
conversion_state = 'partial'
|
||||||
|
|
||||||
|
if display_options['content_url']:
|
||||||
|
conversion_state = 'ready'
|
||||||
|
|
||||||
|
upload_info = None
|
||||||
|
if upload_row:
|
||||||
|
upload_info = {
|
||||||
|
'id': upload_row.id,
|
||||||
|
'state': upload_row.state,
|
||||||
|
'error': upload_row.error,
|
||||||
|
'created_at': upload_row.created_at.isoformat() + 'Z' if upload_row.created_at else None,
|
||||||
|
'updated_at': upload_row.updated_at.isoformat() + 'Z' if upload_row.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
final_state = 'ready' if display_options['content_url'] else None
|
||||||
|
if final_state != 'ready':
|
||||||
|
upload_state = upload_row.state if upload_row else None
|
||||||
|
if conversion_state == 'failed' or upload_state in ('failed', 'conversion_failed'):
|
||||||
|
final_state = 'failed'
|
||||||
|
elif conversion_state in ('processing', 'partial') or upload_state in ('processing', 'pinned'):
|
||||||
|
final_state = 'processing'
|
||||||
|
else:
|
||||||
|
final_state = 'uploaded'
|
||||||
|
|
||||||
|
conversion_info = {
|
||||||
|
'state': conversion_state,
|
||||||
|
'summary': conversion_summary,
|
||||||
|
'details': conversion_details,
|
||||||
|
'required_kinds': list(required_kinds),
|
||||||
|
}
|
||||||
|
|
||||||
|
opts['conversion'] = conversion_info
|
||||||
|
opts['upload'] = upload_info
|
||||||
|
opts['status'] = {
|
||||||
|
'state': final_state,
|
||||||
|
'conversion_state': conversion_state,
|
||||||
|
'upload_state': upload_info['state'] if upload_info else None,
|
||||||
|
}
|
||||||
|
|
||||||
return response.json({
|
return response.json({
|
||||||
**opts,
|
**opts,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from sanic import response
|
from sanic import response
|
||||||
from app.core.models.content_v3 import UploadSession
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.models.content_v3 import UploadSession, EncryptedContent, ContentDerivative
|
||||||
|
from app.core._utils.resolve_content import resolve_content
|
||||||
|
|
||||||
|
|
||||||
async def s_api_v1_upload_status(request, upload_id: str):
|
async def s_api_v1_upload_status(request, upload_id: str):
|
||||||
@@ -7,11 +10,45 @@ async def s_api_v1_upload_status(request, upload_id: str):
|
|||||||
row = await session.get(UploadSession, upload_id)
|
row = await session.get(UploadSession, upload_id)
|
||||||
if not row:
|
if not row:
|
||||||
return response.json({"error": "NOT_FOUND"}, status=404)
|
return response.json({"error": "NOT_FOUND"}, status=404)
|
||||||
|
|
||||||
|
encrypted_hash = None
|
||||||
|
conversion = {"state": "not_started", "details": []}
|
||||||
|
|
||||||
|
if row.encrypted_cid:
|
||||||
|
cid_obj, err = resolve_content(row.encrypted_cid)
|
||||||
|
if not err:
|
||||||
|
encrypted_hash = cid_obj.content_hash_b58
|
||||||
|
ec = (await session.execute(select(EncryptedContent).where(EncryptedContent.encrypted_cid == row.encrypted_cid))).scalars().first()
|
||||||
|
if ec:
|
||||||
|
derivative_rows = (await session.execute(
|
||||||
|
select(ContentDerivative.kind, ContentDerivative.status).where(ContentDerivative.content_id == ec.id)
|
||||||
|
)).all()
|
||||||
|
details = [
|
||||||
|
{"kind": kind, "status": status}
|
||||||
|
for kind, status in derivative_rows
|
||||||
|
]
|
||||||
|
required = {"decrypted_high", "decrypted_low"}
|
||||||
|
if ec.preview_enabled and ec.content_type.startswith("video/"):
|
||||||
|
required.add("decrypted_preview")
|
||||||
|
statuses = {kind: status for kind, status in derivative_rows}
|
||||||
|
if required and all(statuses.get(k) == "ready" for k in required):
|
||||||
|
conv_state = "ready"
|
||||||
|
elif any(statuses.get(k) == "failed" for k in required):
|
||||||
|
conv_state = "failed"
|
||||||
|
elif any(statuses.get(k) in ("processing", "pending") for k in required):
|
||||||
|
conv_state = "processing"
|
||||||
|
elif required:
|
||||||
|
conv_state = "pending"
|
||||||
|
else:
|
||||||
|
conv_state = "not_started"
|
||||||
|
conversion = {"state": conv_state, "details": details}
|
||||||
|
|
||||||
return response.json({
|
return response.json({
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
"state": row.state,
|
"state": row.state,
|
||||||
"encrypted_cid": row.encrypted_cid,
|
"encrypted_cid": row.encrypted_cid,
|
||||||
|
"encrypted_hash": encrypted_hash,
|
||||||
"size_bytes": row.size_bytes,
|
"size_bytes": row.size_bytes,
|
||||||
"error": row.error,
|
"error": row.error,
|
||||||
|
"conversion": conversion,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -6,16 +6,21 @@ import os
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
from base58 import b58encode
|
from base58 import b58encode
|
||||||
from sanic import response
|
from sanic import response
|
||||||
|
|
||||||
|
from app.core._config import UPLOADS_DIR
|
||||||
from app.core._secrets import hot_pubkey
|
from app.core._secrets import hot_pubkey
|
||||||
from app.core.crypto.aes_gcm_stream import encrypt_file_to_encf, CHUNK_BYTES
|
from app.core.crypto.aes_gcm_stream import encrypt_file_to_encf, CHUNK_BYTES
|
||||||
from app.core.crypto.keywrap import wrap_dek, KeyWrapError
|
from app.core.crypto.keywrap import wrap_dek, KeyWrapError
|
||||||
from app.core.ipfs_client import add_streamed_file
|
from app.core.ipfs_client import add_streamed_file
|
||||||
from app.core.logger import make_log
|
from app.core.logger import make_log
|
||||||
from app.core.models.content_v3 import EncryptedContent, ContentKey, IpfsSync, ContentIndexItem, UploadSession
|
from app.core.models.content_v3 import EncryptedContent, ContentKey, IpfsSync, ContentIndexItem, UploadSession
|
||||||
|
from app.core.models.node_storage import StoredContent
|
||||||
from app.core.storage import db_session
|
from app.core.storage import db_session
|
||||||
|
from app.core._utils.resolve_content import resolve_content
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
||||||
def _b64(s: bytes) -> str:
|
def _b64(s: bytes) -> str:
|
||||||
@@ -27,14 +32,29 @@ async def s_api_v1_upload_tus_hook(request):
|
|||||||
tusd HTTP hook endpoint. We mainly handle post-finish to: encrypt -> IPFS add+pin -> record DB.
|
tusd HTTP hook endpoint. We mainly handle post-finish to: encrypt -> IPFS add+pin -> record DB.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
payload: Dict[str, Any] = request.json or {}
|
payload: Dict[str, Any] = request.json
|
||||||
except Exception:
|
except Exception:
|
||||||
payload = {}
|
payload = None
|
||||||
event = payload.get("Type") or payload.get("type") or payload.get("Event") or payload.get("event")
|
if payload is None:
|
||||||
|
raw_body = request.body or b''
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw_body) if raw_body else {}
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
event = (payload.get("Type") or payload.get("type") or
|
||||||
|
payload.get("Event") or payload.get("event") or
|
||||||
|
payload.get("Hook") or payload.get("hook") or
|
||||||
|
payload.get("HookName") or payload.get("hook_name") or
|
||||||
|
request.headers.get("Hook-Name") or request.headers.get("hook-name"))
|
||||||
upload = payload.get("Upload") or payload.get("upload") or {}
|
upload = payload.get("Upload") or payload.get("upload") or {}
|
||||||
|
|
||||||
if not event:
|
if not event:
|
||||||
return response.json({"ok": False, "error": "NO_EVENT"}, status=400)
|
hook_name = (payload.get("HookName") or payload.get("hook") or
|
||||||
|
payload.get("hook_name") or request.headers.get("Hook-Name"))
|
||||||
|
raw = request.body or b''
|
||||||
|
preview = raw[:512]
|
||||||
|
make_log("tus-hook", f"Missing event type in hook payload; ignoring (hook={hook_name}, keys={list(payload.keys())}, raw={preview!r})", level="warning")
|
||||||
|
return response.json({"ok": True, "skipped": True})
|
||||||
|
|
||||||
if event not in ("post-finish", "postfinish"):
|
if event not in ("post-finish", "postfinish"):
|
||||||
# accept but ignore other events
|
# accept but ignore other events
|
||||||
@@ -124,6 +144,12 @@ async def s_api_v1_upload_tus_hook(request):
|
|||||||
except Exception:
|
except Exception:
|
||||||
enc_size = None
|
enc_size = None
|
||||||
|
|
||||||
|
encrypted_cid_obj, cid_err = resolve_content(encrypted_cid)
|
||||||
|
if cid_err:
|
||||||
|
make_log("tus-hook", f"Encrypted CID resolve failed: {cid_err}", level="error")
|
||||||
|
return response.json({"ok": False, "error": "INVALID_ENCRYPTED_CID"}, status=500)
|
||||||
|
encrypted_hash_b58 = encrypted_cid_obj.content_hash_b58
|
||||||
|
|
||||||
# Persist records
|
# Persist records
|
||||||
async with db_session() as session:
|
async with db_session() as session:
|
||||||
ec = EncryptedContent(
|
ec = EncryptedContent(
|
||||||
@@ -150,6 +176,7 @@ async def s_api_v1_upload_tus_hook(request):
|
|||||||
allow_auto_grant=True,
|
allow_auto_grant=True,
|
||||||
)
|
)
|
||||||
session.add(ck)
|
session.add(ck)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
sync = IpfsSync(
|
sync = IpfsSync(
|
||||||
content_id=ec.id,
|
content_id=ec.id,
|
||||||
@@ -160,6 +187,32 @@ async def s_api_v1_upload_tus_hook(request):
|
|||||||
)
|
)
|
||||||
session.add(sync)
|
session.add(sync)
|
||||||
|
|
||||||
|
existing_encrypted_content = (await session.execute(
|
||||||
|
select(StoredContent).where(StoredContent.hash == encrypted_hash_b58)
|
||||||
|
)).scalars().first()
|
||||||
|
if not existing_encrypted_content:
|
||||||
|
placeholder_meta = {
|
||||||
|
'content_type': content_type,
|
||||||
|
'storage': 'ipfs',
|
||||||
|
'encrypted_cid': encrypted_cid,
|
||||||
|
'upload_id': upload_id,
|
||||||
|
'source': 'tusd'
|
||||||
|
}
|
||||||
|
encrypted_stored_content = StoredContent(
|
||||||
|
type="local/encrypted_ipfs",
|
||||||
|
hash=encrypted_hash_b58,
|
||||||
|
content_id=encrypted_cid,
|
||||||
|
filename=os.path.basename(file_path),
|
||||||
|
meta=placeholder_meta,
|
||||||
|
user_id=request.ctx.user.id if request.ctx.user else None,
|
||||||
|
owner_address=None,
|
||||||
|
encrypted=True,
|
||||||
|
decrypted_content_id=None,
|
||||||
|
key_id=None,
|
||||||
|
created=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
session.add(encrypted_stored_content)
|
||||||
|
|
||||||
# Publish signed index item
|
# Publish signed index item
|
||||||
item = {
|
item = {
|
||||||
"encrypted_cid": encrypted_cid,
|
"encrypted_cid": encrypted_cid,
|
||||||
@@ -191,6 +244,9 @@ async def s_api_v1_upload_tus_hook(request):
|
|||||||
if us:
|
if us:
|
||||||
us.state = 'pinned'
|
us.state = 'pinned'
|
||||||
us.encrypted_cid = encrypted_cid
|
us.encrypted_cid = encrypted_cid
|
||||||
|
us.error = None
|
||||||
|
if size:
|
||||||
|
us.size_bytes = size
|
||||||
# prefer using IPFS for downstream conversion; remove staging
|
# prefer using IPFS for downstream conversion; remove staging
|
||||||
try:
|
try:
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path and os.path.exists(file_path):
|
||||||
@@ -201,4 +257,15 @@ async def s_api_v1_upload_tus_hook(request):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
make_log("tus-hook", f"Uploaded+encrypted {file_path} -> {encrypted_cid}")
|
make_log("tus-hook", f"Uploaded+encrypted {file_path} -> {encrypted_cid}")
|
||||||
|
placeholder_path = os.path.join(UPLOADS_DIR, encrypted_hash_b58)
|
||||||
|
if not os.path.exists(placeholder_path):
|
||||||
|
try:
|
||||||
|
async with aiofiles.open(placeholder_path, "wb") as ph:
|
||||||
|
await ph.write(json.dumps({
|
||||||
|
"ipfs_cid": encrypted_cid,
|
||||||
|
"note": "Encrypted payload stored in IPFS"
|
||||||
|
}).encode())
|
||||||
|
except Exception as e:
|
||||||
|
make_log("tus-hook", f"Failed to create placeholder for {encrypted_hash_b58}: {e}", level="warning")
|
||||||
|
|
||||||
return response.json({"ok": True, "encrypted_cid": encrypted_cid, "upload_id": upload_id})
|
return response.json({"ok": True, "encrypted_cid": encrypted_cid, "upload_id": upload_id})
|
||||||
+184
-14
@@ -1,12 +1,16 @@
|
|||||||
import base58
|
import base58
|
||||||
from aiogram import types, Router, F
|
from aiogram import types, Router, F
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from app.core._config import WEB_APP_URLS
|
from app.core._config import WEB_APP_URLS
|
||||||
from app.core._keyboards import get_inline_keyboard
|
from app.core._keyboards import get_inline_keyboard
|
||||||
from app.core._utils.tg_process_template import tg_process_template
|
from app.core._utils.tg_process_template import tg_process_template
|
||||||
from app.core.logger import make_log
|
from app.core.logger import make_log
|
||||||
from app.core.models.node_storage import StoredContent
|
from app.core.models.node_storage import StoredContent
|
||||||
from sqlalchemy import select, and_
|
from app.core.models.content_v3 import UploadSession, EncryptedContent, ContentDerivative
|
||||||
|
from sqlalchemy import select, and_, or_
|
||||||
import json
|
import json
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -18,26 +22,144 @@ def chunks(lst, n):
|
|||||||
yield lst[i:i + n]
|
yield lst[i:i + n]
|
||||||
|
|
||||||
|
|
||||||
|
async def _compute_content_status(db_session, encrypted_cid: Optional[str], fallback_content_type: Optional[str] = None):
|
||||||
|
if not encrypted_cid:
|
||||||
|
return {
|
||||||
|
'final_state': 'uploaded',
|
||||||
|
'conversion_state': 'pending',
|
||||||
|
'upload_state': None,
|
||||||
|
'summary': {},
|
||||||
|
'details': [],
|
||||||
|
'title': None,
|
||||||
|
'content_type': fallback_content_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
ec = (await db_session.execute(select(EncryptedContent).where(EncryptedContent.encrypted_cid == encrypted_cid))).scalars().first()
|
||||||
|
content_type = fallback_content_type or (ec.content_type if ec else None) or 'application/octet-stream'
|
||||||
|
|
||||||
|
derivative_rows = []
|
||||||
|
if ec:
|
||||||
|
derivative_rows = (await db_session.execute(select(ContentDerivative).where(ContentDerivative.content_id == ec.id))).scalars().all()
|
||||||
|
upload_row = (await db_session.execute(select(UploadSession).where(UploadSession.encrypted_cid == encrypted_cid))).scalars().first()
|
||||||
|
|
||||||
|
derivative_sorted = sorted(derivative_rows, key=lambda row: row.created_at or datetime.min)
|
||||||
|
derivative_latest = {}
|
||||||
|
summary = defaultdict(int)
|
||||||
|
details = []
|
||||||
|
for row in derivative_sorted:
|
||||||
|
derivative_latest[row.kind] = row
|
||||||
|
for kind, row in derivative_latest.items():
|
||||||
|
summary[row.status] += 1
|
||||||
|
details.append({
|
||||||
|
'kind': kind,
|
||||||
|
'status': row.status,
|
||||||
|
'size_bytes': row.size_bytes,
|
||||||
|
'error': row.error,
|
||||||
|
'updated_at': (row.last_access_at or row.created_at).isoformat() + 'Z' if (row.last_access_at or row.created_at) else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
required = {'decrypted_low', 'decrypted_high'}
|
||||||
|
if content_type.startswith('video/'):
|
||||||
|
required.add('decrypted_preview')
|
||||||
|
|
||||||
|
statuses_by_kind = {kind: derivative_latest[kind].status for kind in required if kind in derivative_latest}
|
||||||
|
conversion_state = 'pending'
|
||||||
|
if required and all(statuses_by_kind.get(kind) == 'ready' for kind in required):
|
||||||
|
conversion_state = 'ready'
|
||||||
|
elif any(statuses_by_kind.get(kind) == 'failed' for kind in required):
|
||||||
|
conversion_state = 'failed'
|
||||||
|
elif any(statuses_by_kind.get(kind) in ('processing', 'pending') for kind in required):
|
||||||
|
conversion_state = 'processing'
|
||||||
|
elif statuses_by_kind:
|
||||||
|
conversion_state = 'partial'
|
||||||
|
|
||||||
|
upload_state = upload_row.state if upload_row else None
|
||||||
|
final_state = 'ready' if conversion_state == 'ready' else None
|
||||||
|
if not final_state:
|
||||||
|
if conversion_state == 'failed' or upload_state in ('failed', 'conversion_failed'):
|
||||||
|
final_state = 'failed'
|
||||||
|
elif conversion_state in ('processing', 'partial') or upload_state in ('processing', 'pinned'):
|
||||||
|
final_state = 'processing'
|
||||||
|
else:
|
||||||
|
final_state = 'uploaded'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'final_state': final_state,
|
||||||
|
'conversion_state': conversion_state,
|
||||||
|
'upload_state': upload_state,
|
||||||
|
'summary': dict(summary),
|
||||||
|
'details': details,
|
||||||
|
'title': ec.title if ec else None,
|
||||||
|
'content_type': content_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def t_callback_owned_content(query: types.CallbackQuery, memory=None, user=None, db_session=None, chat_wrap=None, **extra):
|
async def t_callback_owned_content(query: types.CallbackQuery, memory=None, user=None, db_session=None, chat_wrap=None, **extra):
|
||||||
message_text = user.translated("ownedContent_menu")
|
message_text = user.translated("ownedContent_menu")
|
||||||
content_list = []
|
content_list = []
|
||||||
user_addr = await user.wallet_address_async(db_session)
|
user_addr = await user.wallet_address_async(db_session)
|
||||||
result = await db_session.execute(select(StoredContent).where(
|
conditions = []
|
||||||
and_(StoredContent.owner_address == user_addr, StoredContent.type == 'onchain/content')
|
if user_addr:
|
||||||
))
|
conditions.append(and_(StoredContent.owner_address == user_addr, StoredContent.type.like('onchain%')))
|
||||||
for content in result.scalars().all():
|
conditions.append(and_(StoredContent.user_id == user.id, StoredContent.type.like('local/%')))
|
||||||
try:
|
|
||||||
metadata_content = await StoredContent.from_cid_async(db_session, content.json_format()['metadata_cid'])
|
|
||||||
with open(metadata_content.filepath, 'r') as f:
|
|
||||||
metadata_content_json = json.loads(f.read())
|
|
||||||
except BaseException as e:
|
|
||||||
make_log("OwnedContent", f"Can't get metadata content: {e}", level='warning')
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
if not conditions:
|
||||||
|
conditions = [StoredContent.user_id == user.id]
|
||||||
|
|
||||||
|
stmt = select(StoredContent).where(
|
||||||
|
StoredContent.disabled.is_(None),
|
||||||
|
or_(*conditions) if len(conditions) > 1 else conditions[0]
|
||||||
|
).order_by(StoredContent.created.desc())
|
||||||
|
|
||||||
|
rows = (await db_session.execute(stmt)).scalars().all()
|
||||||
|
|
||||||
|
onchain_hashes = set()
|
||||||
|
local_items = []
|
||||||
|
|
||||||
|
icon_map = {
|
||||||
|
'ready': '✅',
|
||||||
|
'processing': '⏳',
|
||||||
|
'failed': '⚠️',
|
||||||
|
'uploaded': '📦',
|
||||||
|
}
|
||||||
|
|
||||||
|
for content in rows:
|
||||||
|
meta = content.meta or {}
|
||||||
|
encrypted_cid = meta.get('content_cid') or meta.get('encrypted_cid') or content.content_id
|
||||||
|
status_info = await _compute_content_status(db_session, encrypted_cid, meta.get('content_type'))
|
||||||
|
icon = icon_map.get(status_info['final_state'], '📦')
|
||||||
|
|
||||||
|
if content.type.startswith('onchain'):
|
||||||
|
try:
|
||||||
|
metadata_content = await StoredContent.from_cid_async(db_session, content.json_format()['metadata_cid'])
|
||||||
|
with open(metadata_content.filepath, 'r') as f:
|
||||||
|
metadata_content_json = json.loads(f.read())
|
||||||
|
except BaseException as e:
|
||||||
|
make_log("OwnedContent", f"Can't get metadata content: {e}", level='warning')
|
||||||
|
continue
|
||||||
|
|
||||||
|
onchain_hashes.add(content.hash)
|
||||||
|
display_name = metadata_content_json.get('name') or content.cid.serialize_v2()
|
||||||
|
content_list.append([
|
||||||
|
{
|
||||||
|
'text': f"{icon} {display_name}"[:64],
|
||||||
|
'callback_data': f'NC_{content.id}'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
local_items.append((content, status_info, icon))
|
||||||
|
|
||||||
|
for content, status_info, icon in local_items:
|
||||||
|
if content.hash in onchain_hashes:
|
||||||
|
continue
|
||||||
|
meta = content.meta or {}
|
||||||
|
encrypted_cid = meta.get('encrypted_cid') or content.content_id
|
||||||
|
display_name = status_info['title'] or content.filename or content.cid.serialize_v2()
|
||||||
|
button_text = f"{icon} {display_name}"
|
||||||
content_list.append([
|
content_list.append([
|
||||||
{
|
{
|
||||||
'text': metadata_content_json['name'],
|
'text': button_text[:64],
|
||||||
'callback_data': f'NC_{content.id}'
|
'callback_data': f'LC_{content.id}'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -77,3 +199,51 @@ async def t_callback_node_content(query: types.CallbackQuery, memory=None, user=
|
|||||||
|
|
||||||
router.callback_query.register(t_callback_owned_content, F.data == 'ownedContent')
|
router.callback_query.register(t_callback_owned_content, F.data == 'ownedContent')
|
||||||
router.callback_query.register(t_callback_node_content, F.data.startswith('NC_'))
|
router.callback_query.register(t_callback_node_content, F.data.startswith('NC_'))
|
||||||
|
|
||||||
|
|
||||||
|
async def t_callback_local_content(query: types.CallbackQuery, memory=None, user=None, db_session=None, chat_wrap=None, **extra):
|
||||||
|
content_oid = int(query.data.split('_')[1])
|
||||||
|
content = (await db_session.execute(select(StoredContent).where(StoredContent.id == content_oid))).scalars().first()
|
||||||
|
if not content:
|
||||||
|
return await query.answer(user.translated('error_contentNotFound'), show_alert=True)
|
||||||
|
|
||||||
|
upload_id = (content.meta or {}).get('upload_id')
|
||||||
|
upload_session = await db_session.get(UploadSession, upload_id) if upload_id else None
|
||||||
|
|
||||||
|
encrypted_cid = (content.meta or {}).get('encrypted_cid') or content.content_id
|
||||||
|
status_info = await _compute_content_status(db_session, encrypted_cid, (content.meta or {}).get('content_type'))
|
||||||
|
display_name = status_info['title'] or content.filename or content.cid.serialize_v2()
|
||||||
|
state_label = {
|
||||||
|
'ready': 'Готов',
|
||||||
|
'processing': 'Обработка',
|
||||||
|
'failed': 'Ошибка',
|
||||||
|
'uploaded': 'Загружено',
|
||||||
|
}.get(status_info['final_state'], 'Статус неизвестен')
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"<b>{display_name}</b>",
|
||||||
|
f"Состояние: {state_label}"
|
||||||
|
]
|
||||||
|
if upload_session:
|
||||||
|
lines.append(f"Статус загрузки: {upload_session.state}")
|
||||||
|
if upload_session.error:
|
||||||
|
lines.append(f"Ошибка: {upload_session.error}")
|
||||||
|
if status_info['summary']:
|
||||||
|
lines.append("Конвертация:")
|
||||||
|
for status, count in status_info['summary'].items():
|
||||||
|
lines.append(f"• {status}: {count}")
|
||||||
|
|
||||||
|
await chat_wrap.send_message(
|
||||||
|
'\n'.join(lines),
|
||||||
|
message_type='notification',
|
||||||
|
message_meta={'content_id': content.id},
|
||||||
|
reply_markup=get_inline_keyboard([
|
||||||
|
[{
|
||||||
|
'text': user.translated('back_button'),
|
||||||
|
'callback_data': 'ownedContent'
|
||||||
|
}]
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router.callback_query.register(t_callback_local_content, F.data.startswith('LC_'))
|
||||||
@@ -86,7 +86,10 @@ async def t_home_menu(__msg, **extra):
|
|||||||
make_log("Home", f"Home menu args: {args}", level='debug')
|
make_log("Home", f"Home menu args: {args}", level='debug')
|
||||||
if args:
|
if args:
|
||||||
if args[0].startswith('C'):
|
if args[0].startswith('C'):
|
||||||
content = StoredContent.from_cid(db_session, args[0][1:])
|
payload = args[0][1:]
|
||||||
|
if '!' in payload:
|
||||||
|
payload = payload.split('!', 1)[0]
|
||||||
|
content = StoredContent.from_cid(db_session, payload)
|
||||||
return await chat_wrap.send_content(db_session, content, message_id=message_id)
|
return await chat_wrap.send_content(db_session, content, message_id=message_id)
|
||||||
|
|
||||||
return await send_home_menu(chat_wrap, user, wallet_connection, message_id=message_id)
|
return await send_home_menu(chat_wrap, user, wallet_connection, message_id=message_id)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from aiogram import types, Router, F
|
from aiogram import types, Router, F
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.logger import make_log
|
from app.core.logger import make_log
|
||||||
from app.core.models import StarsInvoice
|
from app.core.models import StarsInvoice
|
||||||
|
|
||||||
@@ -12,9 +14,10 @@ async def t_pre_checkout_query_stars_processing(pre_checkout_query: types.PreChe
|
|||||||
|
|
||||||
invoice_id = pre_checkout_query.invoice_payload
|
invoice_id = pre_checkout_query.invoice_payload
|
||||||
|
|
||||||
existing_invoice = db_session.query(StarsInvoice).filter(
|
result = await db_session.execute(
|
||||||
StarsInvoice.external_id == invoice_id
|
select(StarsInvoice).where(StarsInvoice.external_id == invoice_id)
|
||||||
).first()
|
)
|
||||||
|
existing_invoice = result.scalars().first()
|
||||||
if not existing_invoice:
|
if not existing_invoice:
|
||||||
return await pre_checkout_query.answer(ok=False, error_message="Invoice not found")
|
return await pre_checkout_query.answer(ok=False, error_message="Invoice not found")
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from app.core.content.content_id import ContentId
|
|||||||
|
|
||||||
|
|
||||||
def resolve_content(content_id) -> ContentId: # -> [content, error]
|
def resolve_content(content_id) -> ContentId: # -> [content, error]
|
||||||
|
if isinstance(content_id, ContentId):
|
||||||
|
return content_id, None
|
||||||
try:
|
try:
|
||||||
return ContentId.deserialize(content_id), None
|
return ContentId.deserialize(content_id), None
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
return None, f"{e}"
|
return None, f"{e}"
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
STARTAPP_LIMIT = 64
|
||||||
|
|
||||||
|
|
||||||
|
def build_content_links(content_token: str, ref_id: Optional[str], *, project_host: str, bot_username: str):
|
||||||
|
"""Return tuple of (startapp_payload, telegram_url, web_url)."""
|
||||||
|
payload = (content_token or '').strip()
|
||||||
|
if len(payload) > STARTAPP_LIMIT:
|
||||||
|
payload = payload[:STARTAPP_LIMIT]
|
||||||
|
|
||||||
|
telegram_url = f"https://t.me/{bot_username}/content?startapp={payload}"
|
||||||
|
|
||||||
|
query = [('content', content_token)]
|
||||||
|
if ref_id:
|
||||||
|
query.append(('ref', ref_id))
|
||||||
|
web_url = f"{project_host}/viewContent?{urlencode(query)}"
|
||||||
|
|
||||||
|
return payload, telegram_url, web_url
|
||||||
@@ -136,8 +136,7 @@ async def convert_loop(memory):
|
|||||||
]
|
]
|
||||||
if trim_value:
|
if trim_value:
|
||||||
cmd.extend(["--trim", trim_value])
|
cmd.extend(["--trim", trim_value])
|
||||||
if content_kind == "audio":
|
# converter auto-detects audio/video, no explicit flag required
|
||||||
cmd.append("--audio-only") # audio-only flag
|
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
process = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Tuple
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -22,9 +25,41 @@ from app.core.crypto.encf_stream import decrypt_encf_auto
|
|||||||
from app.core.crypto.keywrap import unwrap_dek, wrap_dek, KeyWrapError
|
from app.core.crypto.keywrap import unwrap_dek, wrap_dek, KeyWrapError
|
||||||
from app.core.network.key_client import request_key_from_peer
|
from app.core.network.key_client import request_key_from_peer
|
||||||
from app.core.models.my_network import KnownNode
|
from app.core.models.my_network import KnownNode
|
||||||
|
from app.core._utils.resolve_content import resolve_content
|
||||||
|
from app.core.content.content_id import ContentId
|
||||||
|
|
||||||
|
|
||||||
CONCURRENCY = int(os.getenv("CONVERT_V3_MAX_CONCURRENCY", "3"))
|
CONCURRENCY = int(os.getenv("CONVERT_V3_MAX_CONCURRENCY", "3"))
|
||||||
|
STAGING_SUBDIR = os.getenv("CONVERT_V3_STAGING_SUBDIR", "convert-staging")
|
||||||
|
UPLOADS_PATH = Path(UPLOADS_DIR).resolve()
|
||||||
|
_host_uploads_env = os.getenv("BACKEND_DATA_DIR_HOST")
|
||||||
|
HOST_UPLOADS_PATH = Path(_host_uploads_env).resolve() if _host_uploads_env else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlainStaging:
|
||||||
|
container_path: str
|
||||||
|
host_path: str
|
||||||
|
|
||||||
|
|
||||||
|
def _container_to_host(path: str) -> str:
|
||||||
|
"""Map a container path under UPLOADS_DIR to the host path for docker -v."""
|
||||||
|
if not HOST_UPLOADS_PATH:
|
||||||
|
raise RuntimeError("BACKEND_DATA_DIR_HOST is not configured for convert_v3")
|
||||||
|
real_path = Path(path).resolve()
|
||||||
|
try:
|
||||||
|
real_path.relative_to(UPLOADS_PATH)
|
||||||
|
except ValueError:
|
||||||
|
# Not under uploads; best effort fallback to original string
|
||||||
|
return str(real_path)
|
||||||
|
rel = real_path.relative_to(UPLOADS_PATH)
|
||||||
|
return str(HOST_UPLOADS_PATH / rel)
|
||||||
|
|
||||||
|
|
||||||
|
MEDIA_CONVERTER_CPU_LIMIT = os.getenv("MEDIA_CONVERTER_CPU_LIMIT")
|
||||||
|
MEDIA_CONVERTER_MEM_LIMIT = os.getenv("MEDIA_CONVERTER_MEM_LIMIT")
|
||||||
|
MEDIA_CONVERTER_CPUSET = os.getenv("MEDIA_CONVERTER_CPUSET") or os.getenv("CONVERT_CPUSET")
|
||||||
|
ERROR_TRUNCATE_LIMIT = 512
|
||||||
|
|
||||||
|
|
||||||
def _ensure_dir(path: str):
|
def _ensure_dir(path: str):
|
||||||
@@ -57,27 +92,49 @@ async def _save_derivative(file_path: str, filename: str) -> Tuple[str, int]:
|
|||||||
return file_hash, size
|
return file_hash, size
|
||||||
|
|
||||||
|
|
||||||
async def _run_media_converter(input_host_path: str, input_ext: str, quality: str, trim_value: str | None, is_audio: bool) -> Tuple[str, dict]:
|
async def _run_media_converter(staging: PlainStaging, input_ext: str, quality: str, trim_value: Optional[str], is_audio: bool):
|
||||||
|
if not os.path.exists(staging.container_path):
|
||||||
|
raise FileNotFoundError(f"Plain input missing at {staging.container_path}")
|
||||||
|
|
||||||
|
host_input_path = staging.host_path
|
||||||
|
if not host_input_path or not host_input_path.startswith('/'):
|
||||||
|
host_input_path = os.path.abspath(host_input_path)
|
||||||
|
|
||||||
rid = __import__('uuid').uuid4().hex[:8]
|
rid = __import__('uuid').uuid4().hex[:8]
|
||||||
output_dir_container = f"/tmp/conv_{rid}"
|
output_dir_container = UPLOADS_PATH / "convert-output" / f"conv_{rid}"
|
||||||
output_dir_host = f"/tmp/conv_{rid}"
|
output_dir_host = _container_to_host(output_dir_container)
|
||||||
_ensure_dir(output_dir_host)
|
_ensure_dir(str(output_dir_container))
|
||||||
logs_dir_host = BACKEND_LOGS_DIR_HOST
|
|
||||||
_ensure_dir(logs_dir_host)
|
logs_dir_candidate = os.getenv("BACKEND_LOGS_DIR_HOST", "")
|
||||||
|
logs_dir_host = logs_dir_candidate if logs_dir_candidate else str(HOST_UPLOADS_PATH / "logs" / "converter") if HOST_UPLOADS_PATH else "/tmp/converter-logs"
|
||||||
|
if not logs_dir_host.startswith('/'):
|
||||||
|
logs_dir_host = os.path.join(os.getcwd(), logs_dir_host)
|
||||||
|
try:
|
||||||
|
os.makedirs(logs_dir_host, exist_ok=True)
|
||||||
|
except Exception:
|
||||||
|
fallback_logs = HOST_UPLOADS_PATH / "logs" / "converter" if HOST_UPLOADS_PATH else Path("/tmp/converter-logs")
|
||||||
|
logs_dir_host = str(fallback_logs)
|
||||||
|
os.makedirs(logs_dir_host, exist_ok=True)
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"docker", "run", "--rm",
|
"docker", "run", "--rm",
|
||||||
"-v", f"{input_host_path}:/app/input:ro",
|
"-v", f"{host_input_path}:/app/input:ro",
|
||||||
"-v", f"{output_dir_host}:/app/output",
|
"-v", f"{output_dir_host}:/app/output",
|
||||||
"-v", f"{logs_dir_host}:/app/logs",
|
"-v", f"{logs_dir_host}:/app/logs",
|
||||||
"media_converter",
|
|
||||||
"--ext", input_ext,
|
|
||||||
"--quality", quality,
|
|
||||||
]
|
]
|
||||||
|
if MEDIA_CONVERTER_CPU_LIMIT:
|
||||||
|
cmd.extend(["--cpus", str(MEDIA_CONVERTER_CPU_LIMIT)])
|
||||||
|
if MEDIA_CONVERTER_MEM_LIMIT:
|
||||||
|
cmd.extend(["--memory", str(MEDIA_CONVERTER_MEM_LIMIT)])
|
||||||
|
if MEDIA_CONVERTER_CPUSET:
|
||||||
|
cmd.extend(["--cpuset-cpus", MEDIA_CONVERTER_CPUSET])
|
||||||
|
|
||||||
|
cmd.append("media_converter")
|
||||||
|
cmd.extend(["--ext", input_ext, "--quality", quality])
|
||||||
if trim_value:
|
if trim_value:
|
||||||
cmd.extend(["--trim", trim_value])
|
cmd.extend(["--trim", trim_value])
|
||||||
if is_audio:
|
|
||||||
cmd.append("--audio-only")
|
make_log('convert_v3', f"Run media_converter cmd: {' '.join(cmd)}")
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd,
|
*cmd,
|
||||||
@@ -90,15 +147,15 @@ async def _run_media_converter(input_host_path: str, input_ext: str, quality: st
|
|||||||
|
|
||||||
# Find produced media file and optional output.json
|
# Find produced media file and optional output.json
|
||||||
try:
|
try:
|
||||||
files = os.listdir(output_dir_host)
|
files = os.listdir(output_dir_container)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Read output dir error: {e}")
|
raise RuntimeError(f"Read output dir error: {e}")
|
||||||
media_files = [f for f in files if f != "output.json"]
|
media_files = [f for f in files if f != "output.json"]
|
||||||
if len(media_files) != 1:
|
if len(media_files) != 1:
|
||||||
raise RuntimeError(f"Expected one media file, found {len(media_files)}: {media_files}")
|
raise RuntimeError(f"Expected one media file, found {len(media_files)}: {media_files}")
|
||||||
output_media = os.path.join(output_dir_host, media_files[0])
|
output_media = os.path.join(output_dir_container, media_files[0])
|
||||||
ffprobe_meta = {}
|
ffprobe_meta = {}
|
||||||
out_json = os.path.join(output_dir_host, "output.json")
|
out_json = os.path.join(output_dir_container, "output.json")
|
||||||
if os.path.exists(out_json):
|
if os.path.exists(out_json):
|
||||||
try:
|
try:
|
||||||
with open(out_json, 'r') as f:
|
with open(out_json, 'r') as f:
|
||||||
@@ -108,24 +165,74 @@ async def _run_media_converter(input_host_path: str, input_ext: str, quality: st
|
|||||||
return output_media, ffprobe_meta
|
return output_media, ffprobe_meta
|
||||||
|
|
||||||
|
|
||||||
async def _convert_content(ec: EncryptedContent, input_host_path: str):
|
async def _update_upload_session(ec: EncryptedContent, all_success: bool, errors: List[str]):
|
||||||
content_kind = 'audio' if ec.content_type.startswith('audio/') else ('video' if ec.content_type.startswith('video/') else 'other')
|
async with db_session() as session:
|
||||||
if content_kind == 'other':
|
upload_row = (await session.execute(
|
||||||
return
|
select(UploadSession).where(UploadSession.encrypted_cid == ec.encrypted_cid)
|
||||||
|
)).scalars().first()
|
||||||
|
if upload_row:
|
||||||
|
if all_success:
|
||||||
|
upload_row.state = 'converted'
|
||||||
|
upload_row.error = None
|
||||||
|
elif upload_row.state != 'converted':
|
||||||
|
upload_row.state = 'conversion_failed'
|
||||||
|
if errors:
|
||||||
|
upload_row.error = _short_error(errors[0])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _convert_content(ec: EncryptedContent, staging: PlainStaging):
|
||||||
|
content_kind = 'audio' if ec.content_type.startswith('audio/') else ('video' if ec.content_type.startswith('video/') else 'other')
|
||||||
input_ext = (ec.content_type.split('/')[-1] or 'bin')
|
input_ext = (ec.content_type.split('/')[-1] or 'bin')
|
||||||
is_audio = content_kind == 'audio'
|
is_audio = content_kind == 'audio'
|
||||||
# Required outputs
|
encrypted_hash_b58 = ContentId.deserialize(ec.encrypted_cid).content_hash_b58
|
||||||
required = ['high', 'low', 'low_preview']
|
|
||||||
|
|
||||||
# Preview interval
|
if content_kind == 'other':
|
||||||
|
errors: List[str] = []
|
||||||
|
all_success = True
|
||||||
|
try:
|
||||||
|
file_hash, size_bytes = await _save_derivative(staging.container_path, staging.container_path)
|
||||||
|
plain_path = os.path.join(UPLOADS_DIR, file_hash)
|
||||||
|
plain_filename = f"{ec.encrypted_cid}.{input_ext}" if input_ext else ec.encrypted_cid
|
||||||
|
async with db_session() as session:
|
||||||
|
existing = (await session.execute(select(StoredContent).where(StoredContent.hash == file_hash))).scalars().first()
|
||||||
|
if not existing:
|
||||||
|
sc = StoredContent(
|
||||||
|
type="local/content_bin",
|
||||||
|
hash=file_hash,
|
||||||
|
user_id=None,
|
||||||
|
filename=plain_filename,
|
||||||
|
meta={'encrypted_cid': ec.encrypted_cid, 'kind': 'original'},
|
||||||
|
created=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
session.add(sc)
|
||||||
|
await session.flush()
|
||||||
|
derivative = ContentDerivative(
|
||||||
|
content_id=ec.id,
|
||||||
|
kind='decrypted_original',
|
||||||
|
local_path=plain_path,
|
||||||
|
content_type=ec.content_type,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
status='ready',
|
||||||
|
)
|
||||||
|
session.add(derivative)
|
||||||
|
await session.commit()
|
||||||
|
make_log('convert_v3', f"Stored original derivative for {ec.encrypted_cid}")
|
||||||
|
except Exception as e:
|
||||||
|
all_success = False
|
||||||
|
errors.append(str(e))
|
||||||
|
make_log('convert_v3', f"Convert error {ec.encrypted_cid} opt=original: {e}", level='error')
|
||||||
|
await _update_upload_session(ec, all_success, errors)
|
||||||
|
return
|
||||||
|
|
||||||
|
# audio/video path
|
||||||
|
required = ['high', 'low', 'low_preview']
|
||||||
conf = ec.preview_conf or {}
|
conf = ec.preview_conf or {}
|
||||||
intervals = conf.get('intervals') or [[0, int(conf.get('duration_ms', 30000))]]
|
intervals = conf.get('intervals') or [[0, int(conf.get('duration_ms', 30000))]]
|
||||||
main_interval = intervals[0]
|
main_interval = intervals[0]
|
||||||
trim_value = None
|
|
||||||
start_s = max(0, int(main_interval[0]) // 1000)
|
start_s = max(0, int(main_interval[0]) // 1000)
|
||||||
dur_s = max(1, int((main_interval[1] - main_interval[0]) // 1000) or 30)
|
dur_s = max(1, int((main_interval[1] - main_interval[0]) // 1000) or 30)
|
||||||
trim_value = f"{start_s},{dur_s}"
|
trim_value = f"{start_s}-{start_s + dur_s}"
|
||||||
|
|
||||||
qualities = {
|
qualities = {
|
||||||
'high': 'high',
|
'high': 'high',
|
||||||
@@ -133,81 +240,128 @@ async def _convert_content(ec: EncryptedContent, input_host_path: str):
|
|||||||
'low_preview': 'low',
|
'low_preview': 'low',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
all_success = True
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
for opt in required:
|
for opt in required:
|
||||||
|
derivative_kind = f"decrypted_{opt if opt != 'low_preview' else 'preview'}"
|
||||||
|
derivative_id: Optional[int] = None
|
||||||
try:
|
try:
|
||||||
# Mark derivative processing
|
|
||||||
async with db_session() as session:
|
async with db_session() as session:
|
||||||
cd = ContentDerivative(
|
cd = ContentDerivative(
|
||||||
content_id=ec.id,
|
content_id=ec.id,
|
||||||
kind=f"decrypted_{opt if opt != 'low_preview' else 'preview'}",
|
kind=derivative_kind,
|
||||||
interval_start_ms=main_interval[0] if opt == 'low_preview' else None,
|
interval_start_ms=main_interval[0] if opt == 'low_preview' else None,
|
||||||
interval_end_ms=main_interval[1] if opt == 'low_preview' else None,
|
interval_end_ms=main_interval[1] if opt == 'low_preview' else None,
|
||||||
local_path="",
|
local_path="",
|
||||||
status='processing',
|
status='processing',
|
||||||
)
|
)
|
||||||
session.add(cd)
|
session.add(cd)
|
||||||
|
await session.flush()
|
||||||
|
derivative_id = cd.id
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
out_path, ffprobe = await _run_media_converter(
|
out_path, ffprobe = await _run_media_converter(
|
||||||
input_host_path=input_host_path,
|
staging=staging,
|
||||||
input_ext=input_ext,
|
input_ext=input_ext,
|
||||||
quality=qualities[opt],
|
quality=qualities[opt],
|
||||||
trim_value=trim_value if opt == 'low_preview' else None,
|
trim_value=trim_value if opt == 'low_preview' else None,
|
||||||
is_audio=is_audio,
|
is_audio=is_audio,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save into store and StoredContent
|
|
||||||
file_hash, size_bytes = await _save_derivative(out_path, os.path.basename(out_path))
|
file_hash, size_bytes = await _save_derivative(out_path, os.path.basename(out_path))
|
||||||
|
|
||||||
async with db_session() as session:
|
async with db_session() as session:
|
||||||
sc = StoredContent(
|
sc = (await session.execute(select(StoredContent).where(StoredContent.hash == file_hash))).scalars().first()
|
||||||
type="local/content_bin",
|
meta_payload = {'encrypted_cid': ec.encrypted_cid, 'kind': opt, 'ffprobe_meta': ffprobe}
|
||||||
hash=file_hash,
|
if sc:
|
||||||
user_id=None,
|
sc.type = sc.type or "local/content_bin"
|
||||||
filename=os.path.basename(out_path),
|
sc.filename = os.path.basename(out_path)
|
||||||
meta={'encrypted_cid': ec.encrypted_cid, 'kind': opt, 'ffprobe_meta': ffprobe},
|
sc.meta = meta_payload
|
||||||
created=datetime.utcnow(),
|
sc.updated = datetime.utcnow()
|
||||||
)
|
else:
|
||||||
session.add(sc)
|
sc = StoredContent(
|
||||||
await session.flush()
|
type="local/content_bin",
|
||||||
|
hash=file_hash,
|
||||||
|
user_id=None,
|
||||||
|
filename=os.path.basename(out_path),
|
||||||
|
meta=meta_payload,
|
||||||
|
created=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
session.add(sc)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
# Update derivative record
|
encrypted_sc = (await session.execute(select(StoredContent).where(StoredContent.hash == encrypted_hash_b58))).scalars().first()
|
||||||
cd = (await session.execute(select(ContentDerivative).where(
|
if encrypted_sc:
|
||||||
ContentDerivative.content_id == ec.id,
|
meta = dict(encrypted_sc.meta or {})
|
||||||
ContentDerivative.kind == (f"decrypted_{opt if opt != 'low_preview' else 'preview'}"),
|
converted = dict(meta.get('converted_content') or {})
|
||||||
ContentDerivative.status == 'processing'
|
converted[opt] = file_hash
|
||||||
))).scalars().first()
|
meta['converted_content'] = converted
|
||||||
|
encrypted_sc.meta = meta
|
||||||
|
if opt == 'high':
|
||||||
|
encrypted_sc.decrypted_content_id = sc.id
|
||||||
|
encrypted_sc.updated = datetime.utcnow()
|
||||||
|
|
||||||
|
cd = await session.get(ContentDerivative, derivative_id) if derivative_id else None
|
||||||
if cd:
|
if cd:
|
||||||
cd.local_path = os.path.join(UPLOADS_DIR, file_hash)
|
cd.local_path = os.path.join(UPLOADS_DIR, file_hash)
|
||||||
cd.size_bytes = size_bytes
|
cd.size_bytes = size_bytes
|
||||||
cd.content_type = ('audio/mpeg' if is_audio else 'video/mp4') if opt != 'high' else ec.content_type
|
if is_audio:
|
||||||
|
cd.content_type = 'audio/flac' if opt == 'high' else 'audio/mpeg'
|
||||||
|
else:
|
||||||
|
cd.content_type = ec.content_type if opt == 'high' else 'video/mp4'
|
||||||
cd.status = 'ready'
|
cd.status = 'ready'
|
||||||
|
cd.error = None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
output_parent = Path(out_path).parent
|
||||||
|
shutil.rmtree(output_parent, ignore_errors=True)
|
||||||
make_log('convert_v3', f"Converted {ec.encrypted_cid} opt={opt} -> {file_hash}")
|
make_log('convert_v3', f"Converted {ec.encrypted_cid} opt={opt} -> {file_hash}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
make_log('convert_v3', f"Convert error {ec.encrypted_cid} opt={opt}: {e}", level='error')
|
make_log('convert_v3', f"Convert error {ec.encrypted_cid} opt={opt}: {e}", level='error')
|
||||||
|
all_success = False
|
||||||
|
errors.append(_short_error(e))
|
||||||
async with db_session() as session:
|
async with db_session() as session:
|
||||||
cd = ContentDerivative(
|
cd = await session.get(ContentDerivative, derivative_id) if derivative_id else None
|
||||||
content_id=ec.id,
|
if cd:
|
||||||
kind=f"decrypted_{opt if opt != 'low_preview' else 'preview'}",
|
cd.status = 'failed'
|
||||||
status='failed',
|
cd.error = _short_error(e)
|
||||||
error=str(e),
|
else:
|
||||||
local_path="",
|
session.add(ContentDerivative(
|
||||||
)
|
content_id=ec.id,
|
||||||
session.add(cd)
|
kind=derivative_kind,
|
||||||
|
status='failed',
|
||||||
|
error=_short_error(e),
|
||||||
|
local_path="",
|
||||||
|
))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
await _update_upload_session(ec, all_success, errors)
|
||||||
|
|
||||||
async def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, str]]:
|
|
||||||
|
async def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, PlainStaging]]:
|
||||||
async with db_session() as session:
|
async with db_session() as session:
|
||||||
# Find A/V contents with preview_enabled and no ready low/low_preview derivatives yet
|
# Find A/V contents with preview_enabled and no ready low/low_preview derivatives yet
|
||||||
ecs = (await session.execute(select(EncryptedContent).where(
|
ecs = (await session.execute(select(EncryptedContent).where(
|
||||||
EncryptedContent.preview_enabled == True
|
EncryptedContent.preview_enabled == True
|
||||||
).order_by(EncryptedContent.created_at.desc()))).scalars().all()
|
).order_by(EncryptedContent.created_at.desc()))).scalars().all()
|
||||||
|
|
||||||
picked: List[Tuple[EncryptedContent, str]] = []
|
picked: List[Tuple[EncryptedContent, PlainStaging]] = []
|
||||||
for ec in ecs:
|
for ec in ecs:
|
||||||
|
try:
|
||||||
|
cid_obj, cid_err = resolve_content(ec.encrypted_cid)
|
||||||
|
if cid_err:
|
||||||
|
make_log('convert_v3', f"Skip {ec.encrypted_cid}: resolve error {cid_err}", level='debug')
|
||||||
|
continue
|
||||||
|
encrypted_hash_b58 = cid_obj.content_hash_b58
|
||||||
|
except Exception as exc:
|
||||||
|
make_log('convert_v3', f"Skip {ec.encrypted_cid}: resolve exception {exc}", level='warning')
|
||||||
|
continue
|
||||||
|
|
||||||
|
sc = (await session.execute(select(StoredContent).where(StoredContent.hash == encrypted_hash_b58))).scalars().first()
|
||||||
|
if not sc or sc.onchain_index is None:
|
||||||
|
continue
|
||||||
|
|
||||||
# Check if derivatives already ready
|
# Check if derivatives already ready
|
||||||
rows = (await session.execute(select(ContentDerivative).where(ContentDerivative.content_id == ec.id))).scalars().all()
|
rows = (await session.execute(select(ContentDerivative).where(ContentDerivative.content_id == ec.id))).scalars().all()
|
||||||
kinds_ready = {r.kind for r in rows if r.status == 'ready'}
|
kinds_ready = {r.kind for r in rows if r.status == 'ready'}
|
||||||
@@ -215,11 +369,11 @@ async def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, str]]:
|
|||||||
if required.issubset(kinds_ready):
|
if required.issubset(kinds_ready):
|
||||||
continue
|
continue
|
||||||
# Always decrypt from IPFS using local or remote key
|
# Always decrypt from IPFS using local or remote key
|
||||||
storage_path: str | None = None
|
staging: Optional[PlainStaging] = None
|
||||||
ck = (await session.execute(select(ContentKey).where(ContentKey.content_id == ec.id))).scalars().first()
|
ck = (await session.execute(select(ContentKey).where(ContentKey.content_id == ec.id))).scalars().first()
|
||||||
if ck:
|
if ck:
|
||||||
storage_path = await stage_plain_from_ipfs(ec, ck.key_ciphertext_b64)
|
staging = await stage_plain_from_ipfs(ec, ck.key_ciphertext_b64)
|
||||||
if not storage_path:
|
if not staging:
|
||||||
peers = (await session.execute(select(KnownNode))).scalars().all()
|
peers = (await session.execute(select(KnownNode))).scalars().all()
|
||||||
for peer in peers:
|
for peer in peers:
|
||||||
base_url = f"http://{peer.ip}:{peer.port}"
|
base_url = f"http://{peer.ip}:{peer.port}"
|
||||||
@@ -240,12 +394,12 @@ async def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, str]]:
|
|||||||
)
|
)
|
||||||
session.add(session_ck)
|
session.add(session_ck)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
storage_path = await stage_plain_from_ipfs(ec, dek_b64)
|
staging = await stage_plain_from_ipfs(ec, dek_b64)
|
||||||
if storage_path:
|
if staging:
|
||||||
break
|
break
|
||||||
if not storage_path or not os.path.exists(storage_path):
|
if not staging or not os.path.exists(staging.container_path):
|
||||||
continue
|
continue
|
||||||
picked.append((ec, storage_path))
|
picked.append((ec, staging))
|
||||||
if len(picked) >= limit:
|
if len(picked) >= limit:
|
||||||
break
|
break
|
||||||
return picked
|
return picked
|
||||||
@@ -254,14 +408,14 @@ async def _pick_pending(limit: int) -> List[Tuple[EncryptedContent, str]]:
|
|||||||
async def worker_loop():
|
async def worker_loop():
|
||||||
sem = asyncio.Semaphore(CONCURRENCY)
|
sem = asyncio.Semaphore(CONCURRENCY)
|
||||||
|
|
||||||
async def _run_one(ec: EncryptedContent, input_path: str):
|
async def _run_one(ec: EncryptedContent, staging: PlainStaging):
|
||||||
async with sem:
|
async with sem:
|
||||||
try:
|
try:
|
||||||
await _convert_content(ec, input_path)
|
await _convert_content(ec, staging)
|
||||||
# After successful conversion, attempt to remove staging file to avoid duplicates
|
# After successful conversion, attempt to remove staging file to avoid duplicates
|
||||||
try:
|
try:
|
||||||
if input_path and input_path.startswith("/data/") and os.path.exists(input_path):
|
if staging and staging.container_path and os.path.exists(staging.container_path):
|
||||||
os.remove(input_path)
|
os.remove(staging.container_path)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -273,7 +427,7 @@ async def worker_loop():
|
|||||||
if not batch:
|
if not batch:
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
continue
|
continue
|
||||||
tasks = [asyncio.create_task(_run_one(ec, path)) for (ec, path) in batch]
|
tasks = [asyncio.create_task(_run_one(ec, staging)) for (ec, staging) in batch]
|
||||||
await asyncio.gather(*tasks)
|
await asyncio.gather(*tasks)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
make_log('convert_v3', f"loop error: {e}", level='error')
|
make_log('convert_v3', f"loop error: {e}", level='error')
|
||||||
@@ -285,15 +439,20 @@ async def main_fn(memory):
|
|||||||
await worker_loop()
|
await worker_loop()
|
||||||
|
|
||||||
|
|
||||||
async def stage_plain_from_ipfs(ec: EncryptedContent, dek_wrapped: str) -> str | None:
|
async def stage_plain_from_ipfs(ec: EncryptedContent, dek_wrapped: str) -> Optional[PlainStaging]:
|
||||||
"""Download encrypted ENCF stream from IPFS and decrypt on the fly into a temp file."""
|
"""Download encrypted ENCF stream from IPFS and decrypt on the fly into shared staging."""
|
||||||
import tempfile
|
os.makedirs(UPLOADS_PATH / STAGING_SUBDIR, exist_ok=True)
|
||||||
try:
|
try:
|
||||||
dek = unwrap_dek(dek_wrapped)
|
dek = unwrap_dek(dek_wrapped)
|
||||||
except KeyWrapError as exc:
|
except KeyWrapError as exc:
|
||||||
make_log('convert_v3', f"unwrap failed for {ec.encrypted_cid}: {exc}", level='error')
|
make_log('convert_v3', f"unwrap failed for {ec.encrypted_cid}: {exc}", level='error')
|
||||||
return None
|
return None
|
||||||
tmp = tempfile.NamedTemporaryFile(prefix=f"dec_{ec.encrypted_cid[:8]}_", delete=False)
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
prefix=f"dec_{ec.encrypted_cid[:8]}_",
|
||||||
|
dir=UPLOADS_PATH / STAGING_SUBDIR,
|
||||||
|
delete=False,
|
||||||
|
)
|
||||||
tmp_path = tmp.name
|
tmp_path = tmp.name
|
||||||
tmp.close()
|
tmp.close()
|
||||||
try:
|
try:
|
||||||
@@ -301,7 +460,8 @@ async def stage_plain_from_ipfs(ec: EncryptedContent, dek_wrapped: str) -> str |
|
|||||||
async for ch in cat_stream(ec.encrypted_cid):
|
async for ch in cat_stream(ec.encrypted_cid):
|
||||||
yield ch
|
yield ch
|
||||||
await decrypt_encf_auto(_aiter(), dek, tmp_path)
|
await decrypt_encf_auto(_aiter(), dek, tmp_path)
|
||||||
return tmp_path
|
host_path = _container_to_host(tmp_path)
|
||||||
|
return PlainStaging(container_path=tmp_path, host_path=host_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
make_log('convert_v3', f"decrypt from ipfs failed: {e}", level='error')
|
make_log('convert_v3', f"decrypt from ipfs failed: {e}", level='error')
|
||||||
try:
|
try:
|
||||||
@@ -312,3 +472,8 @@ async def stage_plain_from_ipfs(ec: EncryptedContent, dek_wrapped: str) -> str |
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _short_error(message: str, limit: int = ERROR_TRUNCATE_LIMIT) -> str:
|
||||||
|
if not message:
|
||||||
|
return message
|
||||||
|
message = str(message)
|
||||||
|
return message if len(message) <= limit else message[: limit - 3] + '...'
|
||||||
@@ -25,14 +25,14 @@ async def _evict_over_ttl(now: datetime) -> int:
|
|||||||
removed = 0
|
removed = 0
|
||||||
# Pull TTL from ServiceConfig each time
|
# Pull TTL from ServiceConfig each time
|
||||||
async with db_session() as session:
|
async with db_session() as session:
|
||||||
ttl_days = await ServiceConfig(session).get('DERIVATIVE_CACHE_TTL_DAYS', ENV_TTL_DAYS)
|
ttl_days = int(await ServiceConfig(session).get('DERIVATIVE_CACHE_TTL_DAYS', ENV_TTL_DAYS))
|
||||||
if int(ttl_days) <= 0:
|
if ttl_days <= 0:
|
||||||
return 0
|
return 0
|
||||||
async with db_session() as session:
|
async with db_session() as session:
|
||||||
rows = (await session.execute(select(ContentDerivative).where(ContentDerivative.status == 'ready'))).scalars().all()
|
rows = (await session.execute(select(ContentDerivative).where(ContentDerivative.status == 'ready'))).scalars().all()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
la = r.last_access_at or r.created_at
|
la = r.last_access_at or r.created_at
|
||||||
if la and (now - la) > timedelta(days=TTL_DAYS):
|
if la and (now - la) > timedelta(days=ttl_days):
|
||||||
try:
|
try:
|
||||||
if r.local_path and os.path.exists(r.local_path):
|
if r.local_path and os.path.exists(r.local_path):
|
||||||
os.remove(r.local_path)
|
os.remove(r.local_path)
|
||||||
@@ -80,7 +80,11 @@ async def _evict_to_fit():
|
|||||||
|
|
||||||
|
|
||||||
async def main_fn(memory):
|
async def main_fn(memory):
|
||||||
make_log('derivative_janitor', f"Started (MAX_GB={MAX_GB}, TTL_DAYS={TTL_DAYS})", level='info')
|
async with db_session() as session:
|
||||||
|
cfg = ServiceConfig(session)
|
||||||
|
runtime_max_gb = float(await cfg.get('DERIVATIVE_CACHE_MAX_GB', ENV_MAX_GB))
|
||||||
|
runtime_ttl_days = int(await cfg.get('DERIVATIVE_CACHE_TTL_DAYS', ENV_TTL_DAYS))
|
||||||
|
make_log('derivative_janitor', f"Started (MAX_GB={runtime_max_gb}, TTL_DAYS={runtime_ttl_days})", level='info')
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import List
|
import os
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import random
|
import random
|
||||||
@@ -18,7 +19,7 @@ ENV_PIN_CONCURRENCY = int(os.getenv('SYNC_MAX_CONCURRENT_PINS', '4'))
|
|||||||
ENV_DISK_WATERMARK_PCT = int(os.getenv('SYNC_DISK_LOW_WATERMARK_PCT', '90'))
|
ENV_DISK_WATERMARK_PCT = int(os.getenv('SYNC_DISK_LOW_WATERMARK_PCT', '90'))
|
||||||
|
|
||||||
|
|
||||||
async def fetch_index(base_url: str, etag: str | None, since: str | None) -> tuple[List[dict], str | None]:
|
async def fetch_index(base_url: str, etag: Optional[str], since: Optional[str]) -> tuple[List[dict], Optional[str]]:
|
||||||
try:
|
try:
|
||||||
headers = {}
|
headers = {}
|
||||||
params = {}
|
params = {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -6,7 +7,7 @@ from base58 import b58encode
|
|||||||
from sqlalchemy import String, and_, desc, cast
|
from sqlalchemy import String, and_, desc, cast
|
||||||
from tonsdk.boc import Cell
|
from tonsdk.boc import Cell
|
||||||
from tonsdk.utils import Address
|
from tonsdk.utils import Address
|
||||||
from app.core._config import CLIENT_TELEGRAM_BOT_USERNAME
|
from app.core._config import CLIENT_TELEGRAM_BOT_USERNAME, PROJECT_HOST
|
||||||
from app.core._blockchain.ton.platform import platform
|
from app.core._blockchain.ton.platform import platform
|
||||||
from app.core._blockchain.ton.toncenter import toncenter
|
from app.core._blockchain.ton.toncenter import toncenter
|
||||||
from app.core._utils.send_status import send_status
|
from app.core._utils.send_status import send_status
|
||||||
@@ -16,8 +17,10 @@ from app.core.models.user import User
|
|||||||
from app.core.models.node_storage import StoredContent
|
from app.core.models.node_storage import StoredContent
|
||||||
from app.core._utils.resolve_content import resolve_content
|
from app.core._utils.resolve_content import resolve_content
|
||||||
from app.core.models.wallet_connection import WalletConnection
|
from app.core.models.wallet_connection import WalletConnection
|
||||||
from app.core._keyboards import get_inline_keyboard
|
|
||||||
from app.core.models._telegram import Wrapped_CBotChat
|
from app.core.models._telegram import Wrapped_CBotChat
|
||||||
|
|
||||||
|
|
||||||
|
MIN_ONCHAIN_INDEX = int(os.getenv("MIN_ONCHAIN_INDEX", "8"))
|
||||||
from sqlalchemy import select, and_, desc
|
from sqlalchemy import select, and_, desc
|
||||||
from app.core.storage import db_session
|
from app.core.storage import db_session
|
||||||
import os
|
import os
|
||||||
@@ -110,11 +113,15 @@ async def indexer_loop(memory, platform_found: bool, seqno: int) -> [bool, int]:
|
|||||||
)).scalars().first()
|
)).scalars().first()
|
||||||
last_known_index = last_known_index_.onchain_index if last_known_index_ else 0
|
last_known_index = last_known_index_.onchain_index if last_known_index_ else 0
|
||||||
last_known_index = max(last_known_index, 0)
|
last_known_index = max(last_known_index, 0)
|
||||||
|
if last_known_index < (MIN_ONCHAIN_INDEX - 1):
|
||||||
|
make_log(
|
||||||
|
"Indexer",
|
||||||
|
f"Adjusting last_known_index from {last_known_index} to {MIN_ONCHAIN_INDEX - 1} (MIN_ONCHAIN_INDEX)",
|
||||||
|
level="debug"
|
||||||
|
)
|
||||||
|
last_known_index = MIN_ONCHAIN_INDEX - 1
|
||||||
make_log("Indexer", f"Last known index: {last_known_index}", level="debug")
|
make_log("Indexer", f"Last known index: {last_known_index}", level="debug")
|
||||||
if last_known_index_:
|
next_item_index = last_known_index + 1
|
||||||
next_item_index = last_known_index + 1
|
|
||||||
else:
|
|
||||||
next_item_index = 0
|
|
||||||
|
|
||||||
resolve_item_result = await toncenter.run_get_method(platform.address.to_string(1, 1, 1), 'get_nft_address_by_index', [['num', next_item_index]])
|
resolve_item_result = await toncenter.run_get_method(platform.address.to_string(1, 1, 1), 'get_nft_address_by_index', [['num', next_item_index]])
|
||||||
make_log("Indexer", f"Resolve item result: {resolve_item_result}", level="debug")
|
make_log("Indexer", f"Resolve item result: {resolve_item_result}", level="debug")
|
||||||
@@ -141,6 +148,13 @@ async def indexer_loop(memory, platform_found: bool, seqno: int) -> [bool, int]:
|
|||||||
|
|
||||||
assert item_get_data_result['stack'][2][0] == 'num', "Item index is not a number"
|
assert item_get_data_result['stack'][2][0] == 'num', "Item index is not a number"
|
||||||
item_index = int(item_get_data_result['stack'][2][1], 16)
|
item_index = int(item_get_data_result['stack'][2][1], 16)
|
||||||
|
if item_index < MIN_ONCHAIN_INDEX:
|
||||||
|
make_log(
|
||||||
|
"Indexer",
|
||||||
|
f"Skip on-chain item {item_index}: below MIN_ONCHAIN_INDEX={MIN_ONCHAIN_INDEX}",
|
||||||
|
level="info"
|
||||||
|
)
|
||||||
|
return platform_found, seqno
|
||||||
assert item_index == next_item_index, "Item index mismatch"
|
assert item_index == next_item_index, "Item index mismatch"
|
||||||
|
|
||||||
item_platform_address = Cell.one_from_boc(b64decode(item_get_data_result['stack'][3][1]['bytes'])).begin_parse().read_msg_addr()
|
item_platform_address = Cell.one_from_boc(b64decode(item_get_data_result['stack'][3][1]['bytes'])).begin_parse().read_msg_addr()
|
||||||
@@ -222,18 +236,24 @@ async def indexer_loop(memory, platform_found: bool, seqno: int) -> [bool, int]:
|
|||||||
|
|
||||||
if user:
|
if user:
|
||||||
user_uploader_wrapper = Wrapped_CBotChat(memory._telegram_bot, chat_id=user.telegram_id, user=user, db_session=session)
|
user_uploader_wrapper = Wrapped_CBotChat(memory._telegram_bot, chat_id=user.telegram_id, user=user, db_session=session)
|
||||||
|
ref_id = (user.meta or {}).get('ref_id')
|
||||||
|
if not ref_id:
|
||||||
|
ref_id = user.ensure_ref_id()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
message_text = user.translated('p_contentWasIndexed').format(
|
||||||
|
item_address=item_address.to_string(1, 1, 1),
|
||||||
|
item_index=item_index,
|
||||||
|
)
|
||||||
|
|
||||||
await user_uploader_wrapper.send_message(
|
await user_uploader_wrapper.send_message(
|
||||||
user.translated('p_contentWasIndexed').format(
|
message_text,
|
||||||
item_address=item_address.to_string(1, 1, 1),
|
message_type='notification'
|
||||||
item_index=item_index,
|
)
|
||||||
),
|
|
||||||
message_type='notification',
|
await user_uploader_wrapper.send_content(
|
||||||
reply_markup=get_inline_keyboard([
|
session,
|
||||||
[{
|
encrypted_stored_content
|
||||||
'text': user.translated('viewTrackAsClient_button'),
|
|
||||||
'url': f"https://t.me/{CLIENT_TELEGRAM_BOT_USERNAME}?start=C{encrypted_stored_content.cid.serialize_v2()}"
|
|
||||||
}],
|
|
||||||
])
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -263,6 +283,7 @@ async def indexer_loop(memory, platform_found: bool, seqno: int) -> [bool, int]:
|
|||||||
**encrypted_stored_content.meta,
|
**encrypted_stored_content.meta,
|
||||||
**item_metadata_packed
|
**item_metadata_packed
|
||||||
}
|
}
|
||||||
|
encrypted_stored_content.content_id = item_content_cid_str
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return platform_found, seqno
|
return platform_found, seqno
|
||||||
@@ -283,6 +304,7 @@ async def indexer_loop(memory, platform_found: bool, seqno: int) -> [bool, int]:
|
|||||||
encrypted=True,
|
encrypted=True,
|
||||||
decrypted_content_id=None,
|
decrypted_content_id=None,
|
||||||
key_id=None,
|
key_id=None,
|
||||||
|
content_id=item_content_cid_str,
|
||||||
updated=datetime.now()
|
updated=datetime.now()
|
||||||
)
|
)
|
||||||
session.add(onchain_stored_content)
|
session.add(onchain_stored_content)
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ async def license_index_loop(memory, platform_found: bool, seqno: int) -> [bool,
|
|||||||
process_content = (await session.execute(select(UserContent).where(
|
process_content = (await session.execute(select(UserContent).where(
|
||||||
and_(
|
and_(
|
||||||
UserContent.type.startswith('nft/'),
|
UserContent.type.startswith('nft/'),
|
||||||
|
UserContent.type != 'nft/ignored',
|
||||||
UserContent.updated < (datetime.now() - timedelta(minutes=60)),
|
UserContent.updated < (datetime.now() - timedelta(minutes=60)),
|
||||||
)
|
)
|
||||||
).order_by(UserContent.updated.asc()))).scalars().first()
|
).order_by(UserContent.updated.asc()))).scalars().first()
|
||||||
|
|||||||
+189
-13
@@ -1,3 +1,6 @@
|
|||||||
|
from base64 import b32decode
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from base58 import b58encode, b58decode
|
from base58 import b58encode, b58decode
|
||||||
|
|
||||||
from tonsdk.boc import begin_cell
|
from tonsdk.boc import begin_cell
|
||||||
@@ -12,25 +15,50 @@ from app.core._utils.string_binary import string_to_bytes_fixed_size, bytes_to_s
|
|||||||
# cid_v2#_ cid_version:uint8 content_sha256:uint256 *[Param]s = CIDv2;
|
# cid_v2#_ cid_version:uint8 content_sha256:uint256 *[Param]s = CIDv2;
|
||||||
|
|
||||||
class ContentId:
|
class ContentId:
|
||||||
|
"""Unified abstraction for legacy ContentID and ENCF/IPFS CID strings."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
version: int = None,
|
version: Optional[int] = None,
|
||||||
content_hash: bytes = None, # only SHA256
|
content_hash: Optional[bytes] = None, # only SHA256
|
||||||
onchain_index: int = None,
|
onchain_index: Optional[int] = None,
|
||||||
accept_type: str = None,
|
accept_type: Optional[str] = None,
|
||||||
encryption_key_sha256: bytes = None,
|
encryption_key_sha256: Optional[bytes] = None,
|
||||||
|
*,
|
||||||
|
raw_value: Optional[str] = None,
|
||||||
|
cid_format: Optional[str] = None,
|
||||||
|
multibase_prefix: Optional[str] = None,
|
||||||
|
multicodec: Optional[int] = None,
|
||||||
|
multihash_code: Optional[int] = 0x12,
|
||||||
|
multihash_length: Optional[int] = 32,
|
||||||
):
|
):
|
||||||
self.version = version
|
self.version = version
|
||||||
self.content_hash = content_hash
|
self.content_hash = content_hash
|
||||||
|
|
||||||
self.onchain_index = onchain_index or -1
|
self.onchain_index = onchain_index if onchain_index is not None else -1
|
||||||
self.accept_type = accept_type
|
self.accept_type = accept_type
|
||||||
self.encryption_key_sha256 = encryption_key_sha256
|
self.encryption_key_sha256 = encryption_key_sha256
|
||||||
if self.encryption_key_sha256:
|
if self.encryption_key_sha256:
|
||||||
assert len(self.encryption_key_sha256) == 32, "Invalid encryption key length"
|
assert len(self.encryption_key_sha256) == 32, "Invalid encryption key length"
|
||||||
|
|
||||||
|
self._raw_value = raw_value
|
||||||
|
if cid_format:
|
||||||
|
self.cid_format = cid_format
|
||||||
|
else:
|
||||||
|
if self.version == 1:
|
||||||
|
self.cid_format = 'content_id_v1'
|
||||||
|
elif self.version == 2:
|
||||||
|
self.cid_format = 'content_id_v2'
|
||||||
|
else:
|
||||||
|
self.cid_format = 'content_id_v2'
|
||||||
|
self.multibase_prefix = multibase_prefix
|
||||||
|
self.multicodec = multicodec
|
||||||
|
self.multihash_code = multihash_code
|
||||||
|
self.multihash_length = multihash_length
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def content_hash_b58(self) -> str:
|
def content_hash_b58(self) -> str:
|
||||||
|
assert self.content_hash, "Content hash is not set"
|
||||||
return b58encode(self.content_hash).decode()
|
return b58encode(self.content_hash).decode()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -38,6 +66,11 @@ class ContentId:
|
|||||||
return self.onchain_index if (not (self.onchain_index is None) and self.onchain_index >= 0) else None
|
return self.onchain_index if (not (self.onchain_index is None) and self.onchain_index >= 0) else None
|
||||||
|
|
||||||
def serialize_v2(self, include_accept_type=False) -> str:
|
def serialize_v2(self, include_accept_type=False) -> str:
|
||||||
|
if self.cid_format == 'ipfs':
|
||||||
|
if self._raw_value:
|
||||||
|
return self._raw_value
|
||||||
|
return self._serialize_ipfs()
|
||||||
|
|
||||||
cid_bin = (
|
cid_bin = (
|
||||||
(2).to_bytes(1, 'big') # cid version
|
(2).to_bytes(1, 'big') # cid version
|
||||||
+ self.content_hash
|
+ self.content_hash
|
||||||
@@ -60,6 +93,8 @@ class ContentId:
|
|||||||
return b58encode(cid_bin).decode()
|
return b58encode(cid_bin).decode()
|
||||||
|
|
||||||
def serialize_v1(self) -> str:
|
def serialize_v1(self) -> str:
|
||||||
|
if self.cid_format == 'ipfs':
|
||||||
|
raise ValueError("Cannot serialize IPFS CID as ContentId v1")
|
||||||
at_bin = string_to_bytes_fixed_size(self.accept_type, 15)
|
at_bin = string_to_bytes_fixed_size(self.accept_type, 15)
|
||||||
assert len(self.content_hash) == 32, "Invalid hash length"
|
assert len(self.content_hash) == 32, "Invalid hash length"
|
||||||
if self.onchain_index < 0:
|
if self.onchain_index < 0:
|
||||||
@@ -133,13 +168,31 @@ class ContentId:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def deserialize(cls, cid: str):
|
def deserialize(cls, cid: str):
|
||||||
cid_version = int.from_bytes(b58decode(cid)[0:1], 'big')
|
if not cid:
|
||||||
|
raise ValueError("Empty content id provided")
|
||||||
|
|
||||||
|
first_char = cid[0]
|
||||||
|
if first_char in ('b', 'B', 'z', 'Z'):
|
||||||
|
return cls.from_ipfs(cid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cid_version = int.from_bytes(b58decode(cid)[0:1], 'big')
|
||||||
|
except Exception:
|
||||||
|
cid_version = None
|
||||||
|
|
||||||
if cid_version == 1:
|
if cid_version == 1:
|
||||||
return cls.from_v1(cid)
|
obj = cls.from_v1(cid)
|
||||||
elif cid_version == 2:
|
obj._raw_value = cid
|
||||||
return cls.from_v2(cid)
|
return obj
|
||||||
else:
|
if cid_version == 2:
|
||||||
raise ValueError("Invalid cid version")
|
obj = cls.from_v2(cid)
|
||||||
|
obj._raw_value = cid
|
||||||
|
return obj
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cls.from_ipfs(cid)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"Invalid cid format: {exc}") from exc
|
||||||
|
|
||||||
def json_format(self):
|
def json_format(self):
|
||||||
return {
|
return {
|
||||||
@@ -147,7 +200,130 @@ class ContentId:
|
|||||||
"content_hash": self.content_hash_b58,
|
"content_hash": self.content_hash_b58,
|
||||||
"onchain_index": self.safe_onchain_index,
|
"onchain_index": self.safe_onchain_index,
|
||||||
"accept_type": self.accept_type,
|
"accept_type": self.accept_type,
|
||||||
"encryption_key_sha256": b58encode(self.encryption_key_sha256).decode() if self.encryption_key_sha256 else None
|
"encryption_key_sha256": b58encode(self.encryption_key_sha256).decode() if self.encryption_key_sha256 else None,
|
||||||
|
"format": self.cid_format,
|
||||||
|
"raw": self.serialize_v2() if self.cid_format == 'ipfs' else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- helpers for IPFS/ENCF CID handling ---------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode_multibase(cid_str: str) -> Tuple[bytes, Optional[str]]:
|
||||||
|
prefix = cid_str[0]
|
||||||
|
if prefix in ('b', 'B'):
|
||||||
|
payload = cid_str[1:]
|
||||||
|
padding = (-len(payload)) % 8
|
||||||
|
decoded = b32decode(payload.upper() + ('=' * padding), casefold=True)
|
||||||
|
return decoded, prefix.lower()
|
||||||
|
if prefix in ('z', 'Z'):
|
||||||
|
return b58decode(cid_str[1:]), prefix.lower()
|
||||||
|
# CIDv0 without explicit prefix
|
||||||
|
return b58decode(cid_str), None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_varint(data: bytes, offset: int) -> Tuple[int, int]:
|
||||||
|
result = 0
|
||||||
|
shift = 0
|
||||||
|
while True:
|
||||||
|
if offset >= len(data):
|
||||||
|
raise ValueError("truncated varint")
|
||||||
|
byte = data[offset]
|
||||||
|
offset += 1
|
||||||
|
result |= (byte & 0x7F) << shift
|
||||||
|
if not (byte & 0x80):
|
||||||
|
break
|
||||||
|
shift += 7
|
||||||
|
if shift > 63:
|
||||||
|
raise ValueError("varint overflow")
|
||||||
|
return result, offset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_ipfs(cls, cid: str):
|
||||||
|
cid = cid.strip()
|
||||||
|
payload, multibase_prefix = cls._decode_multibase(cid)
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
version: Optional[int] = None
|
||||||
|
codec: Optional[int] = None
|
||||||
|
|
||||||
|
if multibase_prefix is not None:
|
||||||
|
version, idx = cls._read_varint(payload, idx)
|
||||||
|
if version not in (0, 1):
|
||||||
|
raise ValueError(f"unsupported CID version: {version}")
|
||||||
|
if version == 1:
|
||||||
|
codec, idx = cls._read_varint(payload, idx)
|
||||||
|
else:
|
||||||
|
codec = 0x70 # dag-pb default for CIDv0
|
||||||
|
else:
|
||||||
|
# CIDv0 without explicit version/codec
|
||||||
|
version = 0
|
||||||
|
codec = 0x70
|
||||||
|
|
||||||
|
multihash_code, idx = cls._read_varint(payload, idx)
|
||||||
|
multihash_length, idx = cls._read_varint(payload, idx)
|
||||||
|
digest = payload[idx:idx + multihash_length]
|
||||||
|
if len(digest) != multihash_length:
|
||||||
|
raise ValueError("truncated multihash digest")
|
||||||
|
if multihash_length != 32:
|
||||||
|
raise ValueError("unsupported multihash length (expected 32 bytes)")
|
||||||
|
if multihash_code != 0x12:
|
||||||
|
raise ValueError(f"unsupported multihash code: {hex(multihash_code)}")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
version=version,
|
||||||
|
content_hash=digest,
|
||||||
|
onchain_index=None,
|
||||||
|
accept_type=None,
|
||||||
|
encryption_key_sha256=None,
|
||||||
|
raw_value=cid,
|
||||||
|
cid_format='ipfs',
|
||||||
|
multibase_prefix=multibase_prefix,
|
||||||
|
multicodec=codec,
|
||||||
|
multihash_code=multihash_code,
|
||||||
|
multihash_length=multihash_length,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _serialize_ipfs(self) -> str:
|
||||||
|
if not self.content_hash:
|
||||||
|
raise ValueError("Cannot serialize IPFS CID without content hash")
|
||||||
|
if self.multibase_prefix is None:
|
||||||
|
# default to CIDv0 (base58btc) dag-pb
|
||||||
|
multihash = self._encode_varint(self.multihash_code or 0x12) + self._encode_varint(self.multihash_length or len(self.content_hash)) + self.content_hash
|
||||||
|
return b58encode(multihash).decode()
|
||||||
|
|
||||||
|
version_bytes = self._encode_varint(self.version or 1)
|
||||||
|
codec_bytes = b''
|
||||||
|
if (self.version or 1) == 1:
|
||||||
|
codec_bytes = self._encode_varint(self.multicodec or 0x70)
|
||||||
|
|
||||||
|
multihash = (
|
||||||
|
version_bytes +
|
||||||
|
codec_bytes +
|
||||||
|
self._encode_varint(self.multihash_code or 0x12) +
|
||||||
|
self._encode_varint(self.multihash_length or len(self.content_hash)) +
|
||||||
|
self.content_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.multibase_prefix == 'z':
|
||||||
|
return 'z' + b58encode(multihash).decode()
|
||||||
|
if self.multibase_prefix == 'b':
|
||||||
|
from base64 import b32encode
|
||||||
|
encoded = b32encode(multihash).decode().rstrip('=').lower()
|
||||||
|
return 'b' + encoded
|
||||||
|
# Fallback to base58btc without prefix
|
||||||
|
return b58encode(multihash).decode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode_varint(value: int) -> bytes:
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError("varint cannot encode negative values")
|
||||||
|
out = bytearray()
|
||||||
|
while True:
|
||||||
|
to_write = value & 0x7F
|
||||||
|
value >>= 7
|
||||||
|
if value:
|
||||||
|
out.append(to_write | 0x80)
|
||||||
|
else:
|
||||||
|
out.append(to_write)
|
||||||
|
break
|
||||||
|
return bytes(out)
|
||||||
@@ -4,7 +4,6 @@ import os
|
|||||||
import struct
|
import struct
|
||||||
from typing import BinaryIO, Iterator, AsyncIterator
|
from typing import BinaryIO, Iterator, AsyncIterator
|
||||||
|
|
||||||
from Crypto.Cipher import SIV
|
|
||||||
from Crypto.Cipher import AES
|
from Crypto.Cipher import AES
|
||||||
|
|
||||||
|
|
||||||
@@ -62,7 +61,7 @@ def encrypt_file_to_encf(src: BinaryIO, key: bytes, chunk_bytes: int, salt: byte
|
|||||||
block = src.read(chunk_bytes)
|
block = src.read(chunk_bytes)
|
||||||
if not block:
|
if not block:
|
||||||
break
|
break
|
||||||
siv = SIV.new(key=key, ciphermod=AES) # new object per message
|
siv = AES.new(key, AES.MODE_SIV) # new object per message
|
||||||
siv.update(_ad(salt, idx))
|
siv.update(_ad(salt, idx))
|
||||||
ciph, tag = siv.encrypt_and_digest(block)
|
ciph, tag = siv.encrypt_and_digest(block)
|
||||||
yield struct.pack(">I", len(block))
|
yield struct.pack(">I", len(block))
|
||||||
@@ -76,7 +75,6 @@ async def decrypt_encf_to_file(byte_iter: AsyncIterator[bytes], key: bytes, out_
|
|||||||
Parse ENCF v1 stream from async byte iterator and write plaintext to out_path.
|
Parse ENCF v1 stream from async byte iterator and write plaintext to out_path.
|
||||||
"""
|
"""
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from Crypto.Cipher import SIV as _SIV
|
|
||||||
from Crypto.Cipher import AES as _AES
|
from Crypto.Cipher import AES as _AES
|
||||||
|
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
@@ -129,7 +127,7 @@ async def decrypt_encf_to_file(byte_iter: AsyncIterator[bytes], key: bytes, out_
|
|||||||
c = bytes(buf[:p_len])
|
c = bytes(buf[:p_len])
|
||||||
t = bytes(buf[p_len:p_len+TAG_LEN])
|
t = bytes(buf[p_len:p_len+TAG_LEN])
|
||||||
del buf[:p_len+TAG_LEN]
|
del buf[:p_len+TAG_LEN]
|
||||||
siv = _SIV.new(key=key, ciphermod=_AES)
|
siv = _AES.new(key, _AES.MODE_SIV)
|
||||||
siv.update(_ad(salt, idx))
|
siv.update(_ad(salt, idx))
|
||||||
p = siv.decrypt_and_verify(c, t)
|
p = siv.decrypt_and_verify(c, t)
|
||||||
await out.write(p)
|
await out.write(p)
|
||||||
|
|||||||
+19
-1
@@ -26,8 +26,19 @@ async def add_streamed_file(stream_iter: Iterable[bytes], filename: str = "file.
|
|||||||
}
|
}
|
||||||
q = {**default_params, **params}
|
q = {**default_params, **params}
|
||||||
|
|
||||||
|
class _StreamAdapter:
|
||||||
|
def __init__(self, iterable):
|
||||||
|
self._iter = iter(iterable)
|
||||||
|
|
||||||
|
def read(self, size=-1):
|
||||||
|
try:
|
||||||
|
return next(self._iter)
|
||||||
|
except StopIteration:
|
||||||
|
return b''
|
||||||
|
|
||||||
|
stream = _StreamAdapter(stream_iter)
|
||||||
async with httpx.AsyncClient(timeout=None) as client:
|
async with httpx.AsyncClient(timeout=None) as client:
|
||||||
files = {"file": (filename, stream_iter, "application/octet-stream")}
|
files = {"file": (filename, stream, "application/octet-stream")}
|
||||||
r = await client.post(f"{IPFS_API_URL}/api/v0/add", params=q, files=files)
|
r = await client.post(f"{IPFS_API_URL}/api/v0/add", params=q, files=files)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
# /add may emit NDJSON lines; most often single JSON
|
# /add may emit NDJSON lines; most often single JSON
|
||||||
@@ -117,3 +128,10 @@ async def repo_stat() -> Dict[str, Any]:
|
|||||||
r = await client.post(f"{IPFS_API_URL}/api/v0/repo/stat")
|
r = await client.post(f"{IPFS_API_URL}/api/v0/repo/stat")
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def id_info() -> Dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
r = await client.post(f"{IPFS_API_URL}/api/v0/id")
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
@@ -11,6 +11,7 @@ import json
|
|||||||
import urllib
|
import urllib
|
||||||
|
|
||||||
from app.core.models.transaction import StarsInvoice
|
from app.core.models.transaction import StarsInvoice
|
||||||
|
from app.core._utils.share_links import build_content_links
|
||||||
|
|
||||||
|
|
||||||
class PlayerTemplates:
|
class PlayerTemplates:
|
||||||
@@ -21,6 +22,9 @@ class PlayerTemplates:
|
|||||||
template_kwargs = {}
|
template_kwargs = {}
|
||||||
inline_keyboard_array = []
|
inline_keyboard_array = []
|
||||||
text = ""
|
text = ""
|
||||||
|
content_metadata_json = {}
|
||||||
|
description_block = ""
|
||||||
|
status_hint = ""
|
||||||
if content:
|
if content:
|
||||||
assert content.type.startswith('onchain/content'), "Invalid nodeStorage content type"
|
assert content.type.startswith('onchain/content'), "Invalid nodeStorage content type"
|
||||||
cd_log = f"Content (SHA256: {content.hash}), Encrypted: {content.encrypted}, TelegramCID: {content.telegram_cid}. "
|
cd_log = f"Content (SHA256: {content.hash}), Encrypted: {content.encrypted}, TelegramCID: {content.telegram_cid}. "
|
||||||
@@ -33,57 +37,72 @@ class PlayerTemplates:
|
|||||||
cd_log += f"Decrypted: {local_content.hash}. "
|
cd_log += f"Decrypted: {local_content.hash}. "
|
||||||
else:
|
else:
|
||||||
cd_log += "Can't decrypt content. "
|
cd_log += "Can't decrypt content. "
|
||||||
|
|
||||||
user_wallet_address = await self.user.wallet_address_async(self.db_session)
|
user_wallet_address = await self.user.wallet_address_async(self.db_session)
|
||||||
user_existing_license = (await self.db_session.execute(select(UserContent).where(
|
user_existing_license = (await self.db_session.execute(select(UserContent).where(
|
||||||
and_(UserContent.user_id == self.user.id, UserContent.content_id == content.id)
|
and_(UserContent.user_id == self.user.id, UserContent.content_id == content.id)
|
||||||
))).scalars().first()
|
))).scalars().first()
|
||||||
|
|
||||||
|
content_meta = content.json_format() if content else {}
|
||||||
if local_content:
|
if local_content:
|
||||||
content_meta = content.json_format()
|
make_log("TG-Player", f"Content meta: {content_meta}. Local content meta: {local_content.json_format()}. ")
|
||||||
local_content_meta = local_content.json_format()
|
|
||||||
make_log("TG-Player", f"Content meta: {content_meta}. Local content meta: {local_content_meta}. ")
|
|
||||||
try:
|
try:
|
||||||
content_type, content_encoding = "audio", "aac"
|
content_type, content_encoding = "audio", "aac"
|
||||||
except:
|
except:
|
||||||
content_type, content_encoding = 'application', 'x-binary'
|
content_type, content_encoding = 'application', 'x-binary'
|
||||||
|
|
||||||
content_metadata = await StoredContent.from_cid_async(db_session, content_meta.get('metadata_cid') or None)
|
metadata_cid = content_meta.get('metadata_cid') if content_meta else None
|
||||||
with open(content_metadata.filepath, 'r') as f:
|
if metadata_cid:
|
||||||
content_metadata_json = json.loads(f.read())
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cover_content = await StoredContent.from_cid_async(self.db_session, content_meta.get('cover_cid') or None)
|
content_metadata = await StoredContent.from_cid_async(db_session, metadata_cid)
|
||||||
cd_log += f"Cover content: {cover_content.cid.serialize_v2()}. "
|
with open(content_metadata.filepath, 'r') as f:
|
||||||
|
content_metadata_json = json.loads(f.read())
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
cd_log += f"Can't get cover content: {e}. "
|
make_log("TG-Player", f"Can't get metadata content: {e}", level='warning')
|
||||||
cover_content = None
|
|
||||||
|
|
||||||
content_share_link = {
|
try:
|
||||||
'text': self.user.translated('p_shareLinkContext').format(title=content_metadata_json.get('name', "")),
|
cover_content = await StoredContent.from_cid_async(self.db_session, content_meta.get('cover_cid') if content_meta else None)
|
||||||
'url': f"https://t.me/{CLIENT_TELEGRAM_BOT_USERNAME}/content?startapp={content.cid.serialize_v2()}"
|
cd_log += f"Cover content: {cover_content.cid.serialize_v2()}. "
|
||||||
}
|
except BaseException as e:
|
||||||
if user_existing_license:
|
cd_log += f"Can't get cover content: {e}. "
|
||||||
content_share_link['url'] = f"https://t.me/{CLIENT_TELEGRAM_BOT_USERNAME}/content?startapp={user_existing_license.onchain_address}"
|
cover_content = None
|
||||||
|
|
||||||
if cover_content:
|
share_target = user_existing_license.onchain_address if user_existing_license else content.cid.serialize_v2()
|
||||||
template_kwargs['photo'] = URLInputFile(cover_content.web_url)
|
ref_id = (self.user.meta or {}).get('ref_id')
|
||||||
|
if not ref_id:
|
||||||
|
ref_id = self.user.ensure_ref_id()
|
||||||
|
if self.db_session:
|
||||||
|
await self.db_session.commit()
|
||||||
|
|
||||||
|
_, startapp_url, web_app_url = build_content_links(
|
||||||
|
share_target,
|
||||||
|
ref_id,
|
||||||
|
project_host=PROJECT_HOST,
|
||||||
|
bot_username=CLIENT_TELEGRAM_BOT_USERNAME
|
||||||
|
)
|
||||||
|
|
||||||
|
content_share_link = {
|
||||||
|
'text': self.user.translated('p_shareLinkContext').format(title=content_metadata_json.get('name', "")),
|
||||||
|
'url': startapp_url,
|
||||||
|
'web_url': web_app_url,
|
||||||
|
'ref_id': ref_id
|
||||||
|
}
|
||||||
|
|
||||||
|
if cover_content:
|
||||||
|
template_kwargs['photo'] = URLInputFile(cover_content.web_url)
|
||||||
|
|
||||||
if not local_content:
|
if not local_content:
|
||||||
text = self.user.translated('p_playerContext_unsupportedContent').format(
|
status_hint = self.user.translated('p_playerContext_contentNotReady')
|
||||||
content_type=content_type,
|
|
||||||
content_encoding=content_encoding
|
|
||||||
)
|
|
||||||
inline_keyboard_array = []
|
|
||||||
extra_buttons = []
|
|
||||||
else:
|
|
||||||
content_hashtags = content_metadata_json.get('description').strip()
|
|
||||||
if content_hashtags:
|
|
||||||
content_hashtags += '\n'
|
|
||||||
|
|
||||||
text = f"""<b>{content_metadata_json.get('name', 'Unnamed')}</b>
|
description = (content_metadata_json.get('description') or '').strip()
|
||||||
{content_hashtags}
|
if description:
|
||||||
Этот контент был загружен в MY
|
description_block = f"{description}\n"
|
||||||
|
|
||||||
|
title = content_metadata_json.get('name') or (local_content.filename if local_content else None) or (content.filename if content else None) or content.cid.serialize_v2()
|
||||||
|
|
||||||
|
status_block = f"{status_hint}\n" if status_hint else ""
|
||||||
|
|
||||||
|
text = f"""<b>{title}</b>
|
||||||
|
{description_block}{status_block}Этот контент был загружен в MY
|
||||||
\t/ p2p content market /
|
\t/ p2p content market /
|
||||||
<blockquote><a href="{content_share_link['url']}">🔴 «открыть в MY»</a></blockquote>"""
|
<blockquote><a href="{content_share_link['url']}">🔴 «открыть в MY»</a></blockquote>"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import os
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import base58
|
import base58
|
||||||
from sqlalchemy import and_, select
|
from sqlalchemy import select
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.logger import make_log
|
from app.core.logger import make_log
|
||||||
from app.core.models import StoredContent
|
from app.core.models import StoredContent
|
||||||
@@ -42,6 +44,9 @@ class NodeStorageIndexationMixin:
|
|||||||
pass # async def fetch_onchain_metadata(self):
|
pass # async def fetch_onchain_metadata(self):
|
||||||
|
|
||||||
|
|
||||||
|
MIN_ONCHAIN_INDEX = int(os.getenv("MIN_ONCHAIN_INDEX", "8"))
|
||||||
|
|
||||||
|
|
||||||
class UserContentIndexationMixin:
|
class UserContentIndexationMixin:
|
||||||
async def sync_with_chain(self, db_session):
|
async def sync_with_chain(self, db_session):
|
||||||
errored = False
|
errored = False
|
||||||
@@ -54,12 +59,30 @@ class UserContentIndexationMixin:
|
|||||||
cc_indexator_data = unpack_item_indexator_data(cc_indexator_result)
|
cc_indexator_data = unpack_item_indexator_data(cc_indexator_result)
|
||||||
assert cc_indexator_data['type'] == 1, "Type is not a content"
|
assert cc_indexator_data['type'] == 1, "Type is not a content"
|
||||||
assert cc_indexator_data['address'] == self.onchain_address, "Address is not equal"
|
assert cc_indexator_data['address'] == self.onchain_address, "Address is not equal"
|
||||||
|
license_type = cc_indexator_data.get('license_type')
|
||||||
|
if cc_indexator_data['index'] < MIN_ONCHAIN_INDEX and (license_type is None or license_type == 0):
|
||||||
|
make_log(
|
||||||
|
"UserContent",
|
||||||
|
f"Skip license {self.onchain_address} with index {cc_indexator_data['index']} < MIN_ONCHAIN_INDEX={MIN_ONCHAIN_INDEX}",
|
||||||
|
level="info"
|
||||||
|
)
|
||||||
|
self.type = 'nft/ignored'
|
||||||
|
self.content_id = None
|
||||||
|
self.updated = datetime.utcnow()
|
||||||
|
await db_session.commit()
|
||||||
|
return
|
||||||
values_slice = cc_indexator_data['values'].begin_parse()
|
values_slice = cc_indexator_data['values'].begin_parse()
|
||||||
content_hash_b58 = base58.b58encode(bytes.fromhex(hex(values_slice.read_uint(256))[2:])).decode()
|
content_hash_b58 = base58.b58encode(bytes.fromhex(hex(values_slice.read_uint(256))[2:])).decode()
|
||||||
make_log("UserContent", f"License ({self.onchain_address}) content hash: {content_hash_b58}", level="info")
|
make_log("UserContent", f"License ({self.onchain_address}) content hash: {content_hash_b58}", level="info")
|
||||||
stored_content = (await db_session.execute(select(StoredContent).where(
|
stored_content = (await db_session.execute(select(StoredContent).where(
|
||||||
and_(StoredContent.type == 'onchain/content', StoredContent.hash == content_hash_b58)
|
StoredContent.hash == content_hash_b58
|
||||||
))).scalars().first()
|
))).scalars().first()
|
||||||
|
if not stored_content:
|
||||||
|
raise AssertionError(f"Stored content not found for hash={content_hash_b58}")
|
||||||
|
if not (stored_content.type or '').startswith('onchain/content'):
|
||||||
|
stored_content.type = 'onchain/content' if stored_content.key_id else 'onchain/content_unknown'
|
||||||
|
stored_content.onchain_index = stored_content.onchain_index or cc_indexator_data['index']
|
||||||
|
stored_content.owner_address = stored_content.owner_address or cc_indexator_data['owner_address']
|
||||||
trusted_cop_address_result = await toncenter.run_get_method(stored_content.meta['item_address'], 'get_nft_address_by_index', [['num', cc_indexator_data['index']]])
|
trusted_cop_address_result = await toncenter.run_get_method(stored_content.meta['item_address'], 'get_nft_address_by_index', [['num', cc_indexator_data['index']]])
|
||||||
assert trusted_cop_address_result.get('exit_code', -1) == 0, "Trusted cop address error"
|
assert trusted_cop_address_result.get('exit_code', -1) == 0, "Trusted cop address error"
|
||||||
trusted_cop_address = Cell.one_from_boc(b64decode(trusted_cop_address_result['stack'][0][1]['bytes'])).begin_parse().read_msg_addr().to_string(1, 1, 1)
|
trusted_cop_address = Cell.one_from_boc(b64decode(trusted_cop_address_result['stack'][0][1]['bytes'])).begin_parse().read_msg_addr().to_string(1, 1, 1)
|
||||||
@@ -68,6 +91,7 @@ class UserContentIndexationMixin:
|
|||||||
self.owner_address = cc_indexator_data['owner_address']
|
self.owner_address = cc_indexator_data['owner_address']
|
||||||
self.type = 'nft/listen'
|
self.type = 'nft/listen'
|
||||||
self.content_id = stored_content.id
|
self.content_id = stored_content.id
|
||||||
|
self.meta = {**(self.meta or {}), 'license_type': license_type}
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
errored = True
|
errored = True
|
||||||
@@ -77,5 +101,3 @@ class UserContentIndexationMixin:
|
|||||||
self.type = 'nft/unknown'
|
self.type = 'nft/unknown'
|
||||||
self.content_id = None
|
self.content_id = None
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +53,11 @@ class StoredContent(AlchemyBase, AudioContentMixin):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def cid(self) -> ContentId:
|
def cid(self) -> ContentId:
|
||||||
|
if self.content_id:
|
||||||
|
try:
|
||||||
|
return ContentId.deserialize(self.content_id)
|
||||||
|
except Exception as exc:
|
||||||
|
make_log("StoredContent", f"Failed to deserialize stored content_id '{self.content_id}': {exc}", level='warning')
|
||||||
return ContentId(
|
return ContentId(
|
||||||
content_hash=b58decode(self.hash),
|
content_hash=b58decode(self.hash),
|
||||||
onchain_index=self.onchain_index,
|
onchain_index=self.onchain_index,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ from app.core.translation import TranslationCore
|
|||||||
from ..base import AlchemyBase
|
from ..base import AlchemyBase
|
||||||
|
|
||||||
|
|
||||||
|
_BASE62_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
_BASE62 = len(_BASE62_ALPHABET)
|
||||||
|
|
||||||
|
|
||||||
class User(AlchemyBase, DisplayMixin, TranslationCore, AuthenticationMixin_V1, WalletMixin):
|
class User(AlchemyBase, DisplayMixin, TranslationCore, AuthenticationMixin_V1, WalletMixin):
|
||||||
LOCALE_DOMAIN = 'sanic_telegram_bot'
|
LOCALE_DOMAIN = 'sanic_telegram_bot'
|
||||||
|
|
||||||
@@ -32,3 +36,26 @@ class User(AlchemyBase, DisplayMixin, TranslationCore, AuthenticationMixin_V1, W
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"User, {self.id}_{self.telegram_id} | Username: {self.username} " + '\\'
|
return f"User, {self.id}_{self.telegram_id} | Username: {self.username} " + '\\'
|
||||||
|
|
||||||
|
def ensure_ref_id(self):
|
||||||
|
"""Return a short referral identifier, generating it if missing."""
|
||||||
|
meta = self.meta or {}
|
||||||
|
ref_id = meta.get('ref_id')
|
||||||
|
if isinstance(ref_id, str) and ref_id:
|
||||||
|
return ref_id
|
||||||
|
|
||||||
|
ref_id = self._generate_ref_id()
|
||||||
|
self.meta = {**meta, 'ref_id': ref_id}
|
||||||
|
return ref_id
|
||||||
|
|
||||||
|
def _generate_ref_id(self):
|
||||||
|
user_id = int(self.id or 0)
|
||||||
|
if user_id <= 0:
|
||||||
|
return '000'
|
||||||
|
|
||||||
|
value = user_id % (_BASE62 ** 3)
|
||||||
|
chars = []
|
||||||
|
for _ in range(3):
|
||||||
|
chars.append(_BASE62_ALPHABET[value % _BASE62])
|
||||||
|
value //= _BASE62
|
||||||
|
|
||||||
|
return ''.join(reversed(chars)) or '000'
|
||||||
@@ -7,6 +7,11 @@ from tonsdk.utils import Address
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from app.core.logger import make_log
|
from app.core.logger import make_log
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
from app.core.models.content.indexation_mixins import unpack_item_indexator_data, MIN_ONCHAIN_INDEX
|
||||||
|
|
||||||
|
def _platform_address_str() -> str:
|
||||||
|
from app.core._blockchain.ton.platform import platform
|
||||||
|
return platform.address.to_string(1, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
class WalletMixin:
|
class WalletMixin:
|
||||||
@@ -43,9 +48,69 @@ class WalletMixin:
|
|||||||
item_address = Address(nft_item['address']).to_string(1, 1, 1)
|
item_address = Address(nft_item['address']).to_string(1, 1, 1)
|
||||||
owner_address = Address(nft_item['owner']['address']).to_string(1, 1, 1)
|
owner_address = Address(nft_item['owner']['address']).to_string(1, 1, 1)
|
||||||
|
|
||||||
|
platform_address = _platform_address_str()
|
||||||
|
collection_address = None
|
||||||
|
if isinstance(nft_item, dict):
|
||||||
|
collection_data = nft_item.get('collection')
|
||||||
|
if isinstance(collection_data, dict):
|
||||||
|
collection_address = collection_data.get('address')
|
||||||
|
collection_address = collection_address or nft_item.get('collection_address')
|
||||||
|
if collection_address:
|
||||||
|
try:
|
||||||
|
collection_address = Address(collection_address).to_string(1, 1, 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
item_index = None
|
||||||
|
license_type = None
|
||||||
|
# Prefer index from tonapi payload if available
|
||||||
|
raw_index = nft_item.get('index') if isinstance(nft_item, dict) else None
|
||||||
|
if isinstance(raw_index, int):
|
||||||
|
item_index = raw_index
|
||||||
|
|
||||||
|
need_chain_probe = item_index is None or item_index < MIN_ONCHAIN_INDEX
|
||||||
|
platform_address_onchain = None
|
||||||
|
if need_chain_probe:
|
||||||
|
try:
|
||||||
|
indexator_raw = await toncenter.run_get_method(item_address, 'indexator_data')
|
||||||
|
if indexator_raw.get('exit_code', -1) == 0:
|
||||||
|
indexator_data = unpack_item_indexator_data(indexator_raw)
|
||||||
|
item_index = indexator_data['index']
|
||||||
|
license_type = indexator_data.get('license_type')
|
||||||
|
platform_address_onchain = indexator_data.get('platform_address')
|
||||||
|
except BaseException as err:
|
||||||
|
make_log(self, f"Failed to fetch indexator data for {item_address}: {err}", level='warning')
|
||||||
|
|
||||||
|
if item_index is None:
|
||||||
|
make_log(self, f"Skip NFT {item_address}: unable to resolve on-chain index", level='warning')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if platform_address_onchain and platform_address_onchain != platform_address:
|
||||||
|
make_log(
|
||||||
|
self,
|
||||||
|
f"Skip foreign NFT {item_address}: platform mismatch {platform_address_onchain} != {platform_address}",
|
||||||
|
level='debug'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item_index < MIN_ONCHAIN_INDEX and (license_type is None or license_type == 0):
|
||||||
|
make_log(
|
||||||
|
self,
|
||||||
|
f"Ignore NFT {item_address} with index {item_index} < MIN_ONCHAIN_INDEX={MIN_ONCHAIN_INDEX} (license_type={license_type})",
|
||||||
|
level='debug'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
user_content = (await db_session.execute(select(UserContent).where(UserContent.onchain_address == item_address))).scalars().first()
|
user_content = (await db_session.execute(select(UserContent).where(UserContent.onchain_address == item_address))).scalars().first()
|
||||||
if user_content:
|
if user_content:
|
||||||
|
if license_type is not None and license_type != 0 and user_content.type == 'nft/ignored':
|
||||||
|
user_content.type = 'nft/unknown'
|
||||||
|
user_content.meta = {**(user_content.meta or {}), 'license_type': license_type}
|
||||||
|
user_content.owner_address = owner_address
|
||||||
|
user_content.status = 'active'
|
||||||
|
user_content.updated = datetime.fromtimestamp(0)
|
||||||
|
await db_session.commit()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
user_content = UserContent(
|
user_content = UserContent(
|
||||||
@@ -57,7 +122,7 @@ class WalletMixin:
|
|||||||
updated=datetime.fromtimestamp(0),
|
updated=datetime.fromtimestamp(0),
|
||||||
content_id=None, # not resolved yet
|
content_id=None, # not resolved yet
|
||||||
created=datetime.now(),
|
created=datetime.now(),
|
||||||
meta={},
|
meta={'license_type': license_type} if license_type is not None else {},
|
||||||
user_id=self.id,
|
user_id=self.id,
|
||||||
wallet_connection_id=(await self.wallet_connection_async(db_session)).id,
|
wallet_connection_id=(await self.wallet_connection_async(db_session)).id,
|
||||||
status="active"
|
status="active"
|
||||||
@@ -83,6 +148,33 @@ class WalletMixin:
|
|||||||
item_address = Address(nft_item['address']).to_string(1, 1, 1)
|
item_address = Address(nft_item['address']).to_string(1, 1, 1)
|
||||||
owner_address = Address(nft_item['owner_address']).to_string(1, 1, 1)
|
owner_address = Address(nft_item['owner_address']).to_string(1, 1, 1)
|
||||||
|
|
||||||
|
platform_address = _platform_address_str()
|
||||||
|
collection_address = nft_item.get('collection_address') if isinstance(nft_item, dict) else None
|
||||||
|
if collection_address:
|
||||||
|
try:
|
||||||
|
normalized_collection = Address(collection_address).to_string(1, 1, 1)
|
||||||
|
except Exception:
|
||||||
|
normalized_collection = collection_address
|
||||||
|
if normalized_collection != platform_address:
|
||||||
|
make_log(self, f"Skip foreign NFT {item_address} from collection {normalized_collection}", level='debug')
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_index = None
|
||||||
|
try:
|
||||||
|
indexator_raw = await toncenter.run_get_method(item_address, 'indexator_data')
|
||||||
|
if indexator_raw.get('exit_code', -1) == 0:
|
||||||
|
item_index = unpack_item_indexator_data(indexator_raw)['index']
|
||||||
|
except BaseException as err:
|
||||||
|
make_log(self, f"Failed to fetch indexator data for {item_address}: {err}", level='warning')
|
||||||
|
|
||||||
|
if item_index is None:
|
||||||
|
make_log(self, f"Skip NFT {item_address}: unable to resolve on-chain index", level='warning')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item_index is not None and item_index < MIN_ONCHAIN_INDEX:
|
||||||
|
make_log(self, f"Ignore NFT {item_address} with index {item_index} < MIN_ONCHAIN_INDEX={MIN_ONCHAIN_INDEX}", level='debug')
|
||||||
|
continue
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
user_content = (await db_session.execute(select(UserContent).where(UserContent.onchain_address == item_address))).scalars().first()
|
user_content = (await db_session.execute(select(UserContent).where(UserContent.onchain_address == item_address))).scalars().first()
|
||||||
if user_content:
|
if user_content:
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
from datetime import datetime
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import httpx
|
import httpx
|
||||||
import threading
|
import threading
|
||||||
import os
|
import os
|
||||||
|
from logging.handlers import TimedRotatingFileHandler
|
||||||
|
|
||||||
PROJSCALE_APP_NAME = os.getenv('APP_PROJSCALE_NAME', 'my-uploader')
|
PROJSCALE_APP_NAME = os.getenv('APP_PROJSCALE_NAME', 'my-uploader')
|
||||||
LOGS_DIRECTORY = os.getenv('APP_LOGS_DIRECTORY', 'logs')
|
LOGS_DIRECTORY = os.getenv('APP_LOGS_DIRECTORY', 'logs')
|
||||||
os.makedirs(LOGS_DIRECTORY, exist_ok=True)
|
os.makedirs(LOGS_DIRECTORY, exist_ok=True)
|
||||||
|
LOG_FILE_BASENAME = os.getenv('APP_LOG_FILE_BASENAME', 'app.log')
|
||||||
|
LOG_ROTATION_KEEP_HOURS = max(int(os.getenv('APP_LOG_ROTATION_KEEP_HOURS', '168')), 1)
|
||||||
|
|
||||||
FORMAT_STRING = '%(asctime)s - %(levelname)s – %(pathname)s – %(funcName)s – %(lineno)d - %(message)s'
|
FORMAT_STRING = '%(asctime)s - %(levelname)s – %(pathname)s – %(funcName)s – %(lineno)d - %(message)s'
|
||||||
|
|
||||||
@@ -62,8 +64,14 @@ projscale_handler = ProjscaleLoggingHandler()
|
|||||||
projscale_handler.setLevel(logging.DEBUG)
|
projscale_handler.setLevel(logging.DEBUG)
|
||||||
logger.addHandler(projscale_handler)
|
logger.addHandler(projscale_handler)
|
||||||
|
|
||||||
log_filepath = f"{LOGS_DIRECTORY}/{datetime.now().strftime('%Y-%m-%d_%H')}.log"
|
log_filepath = os.path.join(LOGS_DIRECTORY, LOG_FILE_BASENAME)
|
||||||
file_handler = logging.FileHandler(log_filepath)
|
file_handler = TimedRotatingFileHandler(
|
||||||
|
log_filepath,
|
||||||
|
when='H',
|
||||||
|
interval=1,
|
||||||
|
backupCount=LOG_ROTATION_KEEP_HOURS,
|
||||||
|
utc=False
|
||||||
|
)
|
||||||
file_handler.setLevel(logging.DEBUG)
|
file_handler.setLevel(logging.DEBUG)
|
||||||
file_handler.setFormatter(logging.Formatter(FORMAT_STRING))
|
file_handler.setFormatter(logging.Formatter(FORMAT_STRING))
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
|
|||||||
Executable
+453
@@ -0,0 +1,453 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ $(id -u) -ne 0 ]]; then
|
||||||
|
echo "This script must be run as root (use sudo)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v lsb_release >/dev/null 2>&1; then
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y lsb-release >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
UBUNTU_CODENAME=$(lsb_release -sc)
|
||||||
|
UBUNTU_MAJOR=$(lsb_release -rs | cut -d'.' -f1)
|
||||||
|
if [[ "$UBUNTU_MAJOR" != "22" ]]; then
|
||||||
|
echo "Warning: this script targets Ubuntu 22.04. Detected $(lsb_release -ds)." >&2
|
||||||
|
read -r -p "Continue anyway? [y/N]: " _cont
|
||||||
|
_cont=${_cont:-N}
|
||||||
|
if [[ ! $_cont =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
if [[ -d "$SCRIPT_DIR/../app" && -d "$SCRIPT_DIR/../scripts" ]]; then
|
||||||
|
DEFAULT_INSTALL_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
|
||||||
|
else
|
||||||
|
DEFAULT_INSTALL_ROOT="/home/my-network"
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -p "Installation root [$DEFAULT_INSTALL_ROOT]: " INSTALL_ROOT || true
|
||||||
|
INSTALL_ROOT=${INSTALL_ROOT:-$DEFAULT_INSTALL_ROOT}
|
||||||
|
INSTALL_ROOT=${INSTALL_ROOT%/}
|
||||||
|
if [[ -z "$INSTALL_ROOT" ]]; then
|
||||||
|
INSTALL_ROOT="/home/my-network"
|
||||||
|
fi
|
||||||
|
mkdir -p "$INSTALL_ROOT"
|
||||||
|
INSTALL_ROOT=$(cd "$INSTALL_ROOT" && pwd)
|
||||||
|
|
||||||
|
echo "Using installation root: $INSTALL_ROOT"
|
||||||
|
|
||||||
|
PROJECT_ROOT="$INSTALL_ROOT"
|
||||||
|
BACKEND_ROOT="$PROJECT_ROOT/uploader-bot"
|
||||||
|
CONFIGS_DIR="$PROJECT_ROOT/configs"
|
||||||
|
FRONTEND_DIR="$PROJECT_ROOT/web2-client"
|
||||||
|
|
||||||
|
ENV_FILE="$CONFIGS_DIR/.env"
|
||||||
|
ENV_EXAMPLE="$BACKEND_ROOT/env.example"
|
||||||
|
|
||||||
|
DEFAULT_BACKEND_REMOTE="https://git.projscale.dev/my-dev/uploader-bot"
|
||||||
|
if [[ -d "$BACKEND_ROOT/.git" ]]; then
|
||||||
|
DEFAULT_BACKEND_REMOTE=$(git -C "$BACKEND_ROOT" config --get remote.origin.url 2>/dev/null || echo "$DEFAULT_BACKEND_REMOTE")
|
||||||
|
fi
|
||||||
|
DEFAULT_CONFIGS_REMOTE="https://git.projscale.dev/my-dev/configs"
|
||||||
|
if [[ -d "$CONFIGS_DIR/.git" ]]; then
|
||||||
|
DEFAULT_CONFIGS_REMOTE=$(git -C "$CONFIGS_DIR" config --get remote.origin.url 2>/dev/null || echo "$DEFAULT_CONFIGS_REMOTE")
|
||||||
|
fi
|
||||||
|
DEFAULT_WEB2_REMOTE="https://git.projscale.dev/my-dev/web2-client"
|
||||||
|
if [[ -d "$FRONTEND_DIR/.git" ]]; then
|
||||||
|
DEFAULT_WEB2_REMOTE=$(git -C "$FRONTEND_DIR" config --get remote.origin.url 2>/dev/null || echo "$DEFAULT_WEB2_REMOTE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
trim() {
|
||||||
|
local val="$1"
|
||||||
|
val="${val#${val%%[![:space:]]*}}"
|
||||||
|
val="${val%${val##*[![:space:]]}}"
|
||||||
|
printf '%s' "$val"
|
||||||
|
}
|
||||||
|
|
||||||
|
ini_val() {
|
||||||
|
local key="$1"
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
awk -F'=' -v k="$key" 'BEGIN{found=0} $1==k{print substr($0,index($0,$2)); found=1; exit} END{if(!found){} }' "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
update_env() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
if grep -qE "^${key}=" "$ENV_FILE"; then
|
||||||
|
sed -i "s|^${key}=.*$|${key}=${value}|" "$ENV_FILE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "${key}=${value}" >> "$ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
RUN_USER=${SUDO_USER:-$(logname 2>/dev/null || echo root)}
|
||||||
|
if ! id -u "$RUN_USER" >/dev/null 2>&1; then
|
||||||
|
RUN_USER=root
|
||||||
|
fi
|
||||||
|
RUN_GROUP=$(id -gn "$RUN_USER" 2>/dev/null || echo $RUN_USER)
|
||||||
|
|
||||||
|
ensure_repo() {
|
||||||
|
local dir="$1"
|
||||||
|
local default_url="$2"
|
||||||
|
local label="$3"
|
||||||
|
|
||||||
|
if [[ -d "$dir/.git" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -d "$dir" && ! -d "$dir/.git" ]]; then
|
||||||
|
echo "Found existing $label directory at $dir without git metadata." >&2
|
||||||
|
read -r -p "Use the existing directory as-is? [y/N]: " reuse || true
|
||||||
|
reuse=${reuse:-N}
|
||||||
|
if [[ ! $reuse =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Please remove or move $dir and rerun the script." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -p "Git URL for $label repository [$default_url]: " repo_url || true
|
||||||
|
repo_url=${repo_url:-$default_url}
|
||||||
|
mkdir -p "$(dirname "$dir")"
|
||||||
|
if ! git clone "$repo_url" "$dir"; then
|
||||||
|
echo "Failed to clone $label repository from $repo_url" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chown -R "$RUN_USER:$RUN_GROUP" "$dir" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_required() {
|
||||||
|
local var="$1"
|
||||||
|
local label="$2"
|
||||||
|
local default_val="${3:-}"
|
||||||
|
local value
|
||||||
|
while true; do
|
||||||
|
if [[ -n "$default_val" ]]; then
|
||||||
|
read -r -p "$label [$default_val]: " value || true
|
||||||
|
value=${value:-$default_val}
|
||||||
|
else
|
||||||
|
read -r -p "$label: " value || true
|
||||||
|
fi
|
||||||
|
value=$(trim "$value")
|
||||||
|
if [[ -n "$value" ]]; then
|
||||||
|
printf -v "$var" '%s' "$value"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "Value is required."
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_optional() {
|
||||||
|
local var="$1"
|
||||||
|
local label="$2"
|
||||||
|
local default_val="${3:-}"
|
||||||
|
local value
|
||||||
|
if [[ -n "$default_val" ]]; then
|
||||||
|
read -r -p "$label [$default_val]: " value || true
|
||||||
|
value=${value:-$default_val}
|
||||||
|
else
|
||||||
|
read -r -p "$label: " value || true
|
||||||
|
fi
|
||||||
|
value=$(trim "$value")
|
||||||
|
printf -v "$var" '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install base dependencies
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y ca-certificates curl gnupg apt-transport-https software-properties-common git make nginx certbot python3-certbot-nginx python3 jq openssl
|
||||||
|
|
||||||
|
# Ensure repository checkouts
|
||||||
|
ensure_repo "$BACKEND_ROOT" "$DEFAULT_BACKEND_REMOTE" "uploader-bot"
|
||||||
|
ensure_repo "$CONFIGS_DIR" "$DEFAULT_CONFIGS_REMOTE" "configs"
|
||||||
|
ensure_repo "$FRONTEND_DIR" "$DEFAULT_WEB2_REMOTE" "web2-client"
|
||||||
|
|
||||||
|
# Prepare environment file
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "Creating $ENV_FILE from example template." >&2
|
||||||
|
if [[ -f "$ENV_EXAMPLE" ]]; then
|
||||||
|
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||||
|
else
|
||||||
|
cp "$CONFIGS_DIR/.env.example" "$ENV_FILE" 2>/dev/null || true
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
touch "$ENV_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
chown "$RUN_USER:$RUN_GROUP" "$ENV_FILE" || true
|
||||||
|
|
||||||
|
# Docker repository setup
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.gpg
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
fi
|
||||||
|
cat <<EOF_REPO >/etc/apt/sources.list.d/docker.list
|
||||||
|
deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $UBUNTU_CODENAME stable
|
||||||
|
EOF_REPO
|
||||||
|
apt-get update -y
|
||||||
|
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
systemctl enable --now docker
|
||||||
|
|
||||||
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
||||||
|
usermod -aG docker "$SUDO_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect interactive inputs
|
||||||
|
EXISTING_DOMAIN=$(ini_val PUBLIC_HOST)
|
||||||
|
EXISTING_DOMAIN=${EXISTING_DOMAIN#https://}
|
||||||
|
EXISTING_DOMAIN=${EXISTING_DOMAIN#http://}
|
||||||
|
DEFAULT_DOMAIN=$(trim "$EXISTING_DOMAIN")
|
||||||
|
prompt_required DOMAIN "Public domain (e.g. node.example.com)" "$DEFAULT_DOMAIN"
|
||||||
|
DOMAIN=$(trim "$DOMAIN")
|
||||||
|
PUBLIC_HOST="https://$DOMAIN"
|
||||||
|
|
||||||
|
prompt_required EMAIL "Email for Let's Encrypt notifications" "$(ini_val CERTBOT_EMAIL)"
|
||||||
|
|
||||||
|
DEFAULT_SEEDS=$(trim "${DEFAULT_DOMAIN:+https://$DEFAULT_DOMAIN}")
|
||||||
|
if [[ -z "$DEFAULT_SEEDS" ]]; then
|
||||||
|
DEFAULT_SEEDS="https://my-public-node-8.projscale.dev"
|
||||||
|
fi
|
||||||
|
prompt_required BOOTSTRAP_SEEDS "Bootstrap seed URLs (comma-separated)" "$DEFAULT_SEEDS"
|
||||||
|
IFS=',' read -r -a _seed_array <<< "$BOOTSTRAP_SEEDS"
|
||||||
|
BOOTSTRAP_SEEDS=""
|
||||||
|
for entry in "${_seed_array[@]}"; do
|
||||||
|
entry=$(trim "$entry")
|
||||||
|
[[ -z "$entry" ]] && continue
|
||||||
|
if [[ $entry != http://* && $entry != https://* ]]; then
|
||||||
|
entry="https://$entry"
|
||||||
|
fi
|
||||||
|
if [[ -z "$BOOTSTRAP_SEEDS" ]]; then
|
||||||
|
BOOTSTRAP_SEEDS="$entry"
|
||||||
|
else
|
||||||
|
BOOTSTRAP_SEEDS="$BOOTSTRAP_SEEDS,$entry"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -z "$BOOTSTRAP_SEEDS" ]]; then
|
||||||
|
echo 'At least one bootstrap seed is required.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
prompt_optional NODE_PRIVACY "Node privacy (public/private)" "$(ini_val NODE_PRIVACY)"
|
||||||
|
if [[ -z "$NODE_PRIVACY" ]]; then
|
||||||
|
NODE_PRIVACY="public"
|
||||||
|
fi
|
||||||
|
NODE_PRIVACY=$(echo "$NODE_PRIVACY" | tr '[:upper:]' '[:lower:]')
|
||||||
|
if [[ "$NODE_PRIVACY" != "public" && "$NODE_PRIVACY" != "private" ]]; then
|
||||||
|
echo "Invalid privacy option, defaulting to public." >&2
|
||||||
|
NODE_PRIVACY="public"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEFAULT_SANIC=$(ini_val SANIC_PORT)
|
||||||
|
DEFAULT_SANIC=${DEFAULT_SANIC:-13200}
|
||||||
|
prompt_optional SANIC_PORT "Internal backend port" "$DEFAULT_SANIC"
|
||||||
|
DEFAULT_BACKEND_PORT=$(ini_val BACKEND_HTTP_PORT)
|
||||||
|
DEFAULT_BACKEND_PORT=${DEFAULT_BACKEND_PORT:-13200}
|
||||||
|
prompt_optional BACKEND_HTTP_PORT "Public backend port" "$DEFAULT_BACKEND_PORT"
|
||||||
|
DEFAULT_FRONTEND_PORT=$(ini_val FRONTEND_HTTP_PORT)
|
||||||
|
DEFAULT_FRONTEND_PORT=${DEFAULT_FRONTEND_PORT:-13300}
|
||||||
|
prompt_optional FRONTEND_HTTP_PORT "Public frontend port" "$DEFAULT_FRONTEND_PORT"
|
||||||
|
DEFAULT_TUSD_PORT=$(ini_val TUSD_HTTP_PORT)
|
||||||
|
DEFAULT_TUSD_PORT=${DEFAULT_TUSD_PORT:-13400}
|
||||||
|
prompt_optional TUSD_HTTP_PORT "Public tusd port" "$DEFAULT_TUSD_PORT"
|
||||||
|
DEFAULT_PG_PORT=$(ini_val POSTGRES_FORWARD_PORT)
|
||||||
|
DEFAULT_PG_PORT=${DEFAULT_PG_PORT:-13580}
|
||||||
|
prompt_optional POSTGRES_FORWARD_PORT "Public Postgres port" "$DEFAULT_PG_PORT"
|
||||||
|
|
||||||
|
prompt_required TELEGRAM_API_KEY "Telegram uploader bot token" "$(ini_val TELEGRAM_API_KEY)"
|
||||||
|
prompt_required CLIENT_TELEGRAM_API_KEY "Telegram client bot token" "$(ini_val CLIENT_TELEGRAM_API_KEY)"
|
||||||
|
|
||||||
|
prompt_optional ADMIN_API_TOKEN "Admin API token" "$(ini_val ADMIN_API_TOKEN)"
|
||||||
|
if [[ -z "$ADMIN_API_TOKEN" ]]; then
|
||||||
|
ADMIN_API_TOKEN=$(openssl rand -hex 16)
|
||||||
|
echo "Generated ADMIN_API_TOKEN=$ADMIN_API_TOKEN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
prompt_required TONCENTER_API_KEY "TON Center API key" "$(ini_val TONCENTER_API_KEY)"
|
||||||
|
prompt_optional TON_INIT_HOT_SEED "TON hot wallet seed hex (leave blank to auto-generate)" "$(ini_val TON_INIT_HOT_SEED)"
|
||||||
|
|
||||||
|
prompt_required CONTENT_KEY_KEK_B64 "CONTENT_KEY_KEK_B64 (32-byte base64)" "$(ini_val CONTENT_KEY_KEK_B64)"
|
||||||
|
if ! python3 - "$CONTENT_KEY_KEK_B64" <<'PY'; then
|
||||||
|
import base64, sys
|
||||||
|
val = sys.argv[1]
|
||||||
|
raw = base64.b64decode(val + '===' , validate=False)
|
||||||
|
if len(raw) != 32:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
echo 'Invalid CONTENT_KEY_KEK_B64: must be base64-encoded 32 bytes.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
prompt_required SWARM_KEY_HEX "IPFS swarm secret (32-byte hex)" ""
|
||||||
|
SWARM_KEY_HEX=$(echo "$SWARM_KEY_HEX" | tr '[:lower:]' '[:upper:]')
|
||||||
|
if ! python3 - "$SWARM_KEY_HEX" <<'PY'; then
|
||||||
|
import sys
|
||||||
|
val = sys.argv[1].strip()
|
||||||
|
bytes.fromhex(val)
|
||||||
|
if len(val) != 64:
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
echo 'Invalid IPFS swarm key: must be 64 hex chars (32 bytes).' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
update_env CERTBOT_EMAIL "$EMAIL"
|
||||||
|
update_env PUBLIC_HOST "$PUBLIC_HOST"
|
||||||
|
update_env PROJECT_HOST "$PUBLIC_HOST"
|
||||||
|
update_env NODE_PRIVACY "$NODE_PRIVACY"
|
||||||
|
update_env BOOTSTRAP_SEEDS "$BOOTSTRAP_SEEDS"
|
||||||
|
update_env SANIC_PORT "$SANIC_PORT"
|
||||||
|
update_env BACKEND_HTTP_PORT "$BACKEND_HTTP_PORT"
|
||||||
|
update_env FRONTEND_HTTP_PORT "$FRONTEND_HTTP_PORT"
|
||||||
|
update_env TUSD_HTTP_PORT "$TUSD_HTTP_PORT"
|
||||||
|
update_env POSTGRES_FORWARD_PORT "$POSTGRES_FORWARD_PORT"
|
||||||
|
update_env TELEGRAM_API_KEY "$TELEGRAM_API_KEY"
|
||||||
|
update_env CLIENT_TELEGRAM_API_KEY "$CLIENT_TELEGRAM_API_KEY"
|
||||||
|
update_env ADMIN_API_TOKEN "$ADMIN_API_TOKEN"
|
||||||
|
update_env TONCENTER_API_KEY "$TONCENTER_API_KEY"
|
||||||
|
if [[ -n "$TON_INIT_HOT_SEED" ]]; then
|
||||||
|
update_env TON_INIT_HOT_SEED "$TON_INIT_HOT_SEED"
|
||||||
|
fi
|
||||||
|
update_env CONTENT_KEY_KEK_B64 "$CONTENT_KEY_KEK_B64"
|
||||||
|
update_env HANDSHAKE_INTERVAL_SEC "60"
|
||||||
|
update_env BOOTSTRAP_REQUIRED "1"
|
||||||
|
update_env VITE_API_BASE_URL "${PUBLIC_HOST}/api/v1"
|
||||||
|
update_env VITE_API_BASE_STORAGE_URL "${PUBLIC_HOST}/api/v1.5/storage"
|
||||||
|
update_env VITE_TUS_ENDPOINT "${PUBLIC_HOST}/tus/files"
|
||||||
|
update_env TON_CONNECT_MANIFEST_URI "${PUBLIC_HOST}/api/tonconnect-manifest.json"
|
||||||
|
|
||||||
|
# Ensure swarm key file
|
||||||
|
mkdir -p "$CONFIGS_DIR/ipfs"
|
||||||
|
cat <<EOF_SWARM >"$CONFIGS_DIR/ipfs/swarm.key"
|
||||||
|
/key/swarm/psk/1.0.0/
|
||||||
|
/base16/
|
||||||
|
$SWARM_KEY_HEX
|
||||||
|
EOF_SWARM
|
||||||
|
chmod 600 "$CONFIGS_DIR/ipfs/swarm.key"
|
||||||
|
update_env IPFS_SWARM_KEY_FILE "$CONFIGS_DIR/ipfs/swarm.key"
|
||||||
|
|
||||||
|
# Issue TLS certificate
|
||||||
|
if [[ ! -d "/etc/letsencrypt/live/$DOMAIN" ]]; then
|
||||||
|
systemctl stop nginx || true
|
||||||
|
certbot certonly --standalone --agree-tos --non-interactive -m "$EMAIL" -d "$DOMAIN"
|
||||||
|
systemctl start nginx
|
||||||
|
fi
|
||||||
|
systemctl enable --now nginx
|
||||||
|
|
||||||
|
# Nginx configuration
|
||||||
|
cat <<EOF_NGX >/etc/nginx/sites-available/my-network.conf
|
||||||
|
upstream backend_app {
|
||||||
|
server 127.0.0.1:$SANIC_PORT;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend_web {
|
||||||
|
server 127.0.0.1:$FRONTEND_HTTP_PORT;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream tusd_backend {
|
||||||
|
server 127.0.0.1:$TUSD_HTTP_PORT;
|
||||||
|
keepalive 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $DOMAIN;
|
||||||
|
return 301 https://$DOMAIN$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name $DOMAIN;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
|
|
||||||
|
client_max_body_size 10G;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend_web;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
proxy_pass http://frontend_web;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_hide_header Cache-Control;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /tus/ {
|
||||||
|
proxy_pass http://tusd_backend/;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_max_temp_file_size 0;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /files/ {
|
||||||
|
proxy_pass http://tusd_backend;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_max_temp_file_size 0;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend_app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /health {
|
||||||
|
proxy_pass http://backend_app/api/system.version;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF_NGX
|
||||||
|
|
||||||
|
ln -sf /etc/nginx/sites-available/my-network.conf /etc/nginx/sites-enabled/my-network.conf
|
||||||
|
rm -f /etc/nginx/sites-enabled/default
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
# Bootstrap docker-compose stack
|
||||||
|
make -C "$CONFIGS_DIR" bootstrap
|
||||||
|
|
||||||
|
echo "\nNode provisioning complete."
|
||||||
|
echo "- Admin panel: ${PUBLIC_HOST}/admin (use ADMIN_API_TOKEN)"
|
||||||
|
echo "- To trust this node on peers, mark it via admin API on existing node."
|
||||||
|
echo "- Docker services: run 'docker ps -a' or 'make -C $CONFIGS_DIR ps'."
|
||||||
|
echo "- Installation root: $PROJECT_ROOT"
|
||||||
Reference in new issue
Block a user