dht
This commit is contained in:
1 parent
c6c6276fe6
commit
698d0ca3f7
49 files changed
+1118
-53
No files matched your search
+12
-1
@@ -25,7 +25,7 @@ from app.api.routes.auth import s_api_v1_auth_twa, s_api_v1_auth_select_wallet,
|
||||
from app.api.routes.statics import s_api_tonconnect_manifest, s_api_platform_metadata
|
||||
from app.api.routes.node_storage import s_api_v1_storage_post, s_api_v1_storage_get, \
|
||||
s_api_v1_storage_decode_cid
|
||||
from app.api.routes.progressive_storage import s_api_v1_5_storage_get, s_api_v1_5_storage_post
|
||||
from app.api.routes.progressive_storage import s_api_v1_5_storage_get, s_api_v1_5_storage_post, s_api_v1_storage_fetch, s_api_v1_storage_proxy
|
||||
from app.api.routes.upload_tus import s_api_v1_upload_tus_hook
|
||||
from app.api.routes.account import s_api_v1_account_get
|
||||
from app.api.routes._blockchain import s_api_v1_blockchain_send_new_content_message, \
|
||||
@@ -52,12 +52,16 @@ from app.api.routes.admin import (
|
||||
s_api_v1_admin_system,
|
||||
s_api_v1_admin_uploads,
|
||||
s_api_v1_admin_users,
|
||||
s_api_v1_admin_network,
|
||||
s_api_v1_admin_network_config,
|
||||
s_api_v1_admin_network_config_set,
|
||||
)
|
||||
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.sync import s_api_v1_sync_pin, s_api_v1_sync_status
|
||||
from app.api.routes.upload_status import s_api_v1_upload_status
|
||||
from app.api.routes.metrics import s_api_metrics
|
||||
from app.api.routes.dht import s_api_v1_dht_get, s_api_v1_dht_put
|
||||
|
||||
|
||||
app.add_route(s_index, "/", methods=["GET", "OPTIONS"])
|
||||
@@ -84,6 +88,8 @@ app.add_route(s_api_v1_tonconnect_logout, "/api/v1/tonconnect.logout", methods=[
|
||||
|
||||
app.add_route(s_api_v1_5_storage_post, "/api/v1.5/storage", methods=["POST", "OPTIONS"])
|
||||
app.add_route(s_api_v1_5_storage_get, "/api/v1.5/storage/<file_hash>", methods=["GET", "OPTIONS"])
|
||||
app.add_route(s_api_v1_storage_fetch, "/api/v1/storage.fetch/<file_hash>", methods=["GET", "OPTIONS"])
|
||||
app.add_route(s_api_v1_storage_proxy, "/api/v1/storage.proxy/<file_hash>", methods=["GET", "OPTIONS"])
|
||||
|
||||
app.add_route(s_api_v1_storage_post, "/api/v1/storage", methods=["POST", "OPTIONS"])
|
||||
app.add_route(s_api_v1_storage_get, "/api/v1/storage/<file_hash>", methods=["GET", "OPTIONS"])
|
||||
@@ -119,6 +125,9 @@ app.add_route(s_api_v1_admin_status, "/api/v1/admin.status", methods=["GET", "OP
|
||||
app.add_route(s_api_v1_admin_cache_setlimits, "/api/v1/admin.cache.setLimits", methods=["POST", "OPTIONS"])
|
||||
app.add_route(s_api_v1_admin_cache_cleanup, "/api/v1/admin.cache.cleanup", methods=["POST", "OPTIONS"])
|
||||
app.add_route(s_api_v1_admin_sync_setlimits, "/api/v1/admin.sync.setLimits", methods=["POST", "OPTIONS"])
|
||||
app.add_route(s_api_v1_admin_network, "/api/v1/admin.network", methods=["GET", "OPTIONS"])
|
||||
app.add_route(s_api_v1_admin_network_config, "/api/v1/admin.network.config", methods=["GET", "OPTIONS"])
|
||||
app.add_route(s_api_v1_admin_network_config_set, "/api/v1/admin.network.config.set", methods=["POST", "OPTIONS"])
|
||||
|
||||
# tusd HTTP hooks
|
||||
app.add_route(s_api_v1_upload_tus_hook, "/api/v1/upload.tus-hook", methods=["POST", "OPTIONS"])
|
||||
@@ -129,6 +138,8 @@ app.add_route(s_api_v1_sync_pin, "/api/v1/sync.pin", methods=["POST", "OPTIONS"]
|
||||
app.add_route(s_api_v1_sync_status, "/api/v1/sync.status", methods=["GET", "OPTIONS"])
|
||||
app.add_route(s_api_v1_upload_status, "/api/v1/upload.status/<upload_id>", methods=["GET", "OPTIONS"])
|
||||
app.add_route(s_api_metrics, "/metrics", methods=["GET", "OPTIONS"])
|
||||
app.add_route(s_api_v1_dht_get, "/api/v1/dht.get", methods=["GET", "OPTIONS"])
|
||||
app.add_route(s_api_v1_dht_put, "/api/v1/dht.put", methods=["POST", "OPTIONS"])
|
||||
|
||||
|
||||
@app.exception(BaseException)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+270
-1
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from base58 import b58encode
|
||||
from app.core._utils.b58 import b58encode
|
||||
from sanic import response
|
||||
from sqlalchemy import Integer, String, and_, case, cast, func, or_, select, Text
|
||||
|
||||
@@ -48,6 +48,9 @@ from app.core.models.user_activity import UserActivity
|
||||
from app.core._utils.share_links import build_content_links
|
||||
from app.core.content.content_id import ContentId
|
||||
from app.core.events.service import record_event
|
||||
from app.core.network.dht import MetricKey # type stub; used in typing only
|
||||
from app.core.network.dht import dht_config
|
||||
from app.core.models._config import ServiceConfig
|
||||
|
||||
MIN_ONCHAIN_INDEX = int(os.getenv("MIN_ONCHAIN_INDEX", "8"))
|
||||
|
||||
@@ -2219,6 +2222,272 @@ async def s_api_v1_admin_cache_cleanup(request):
|
||||
return response.json({"ok": True, "removed": removed})
|
||||
|
||||
|
||||
async def s_api_v1_admin_network(request):
|
||||
"""Сводка состояния децентрализованной сети для вкладки "Состояние сети".
|
||||
|
||||
Возвращает:
|
||||
- summary: n_estimate, количество участников, число островов, сводка конфликтов репликаций
|
||||
- members: список нод с ролями/версиями/достижимостью и атрибутами
|
||||
- per_node_replication: сколько лизов держит каждая нода и сколько раз является лидером
|
||||
"""
|
||||
if (unauth := _ensure_admin(request)):
|
||||
return unauth
|
||||
|
||||
mem = getattr(request.app.ctx, 'memory', None)
|
||||
if not mem:
|
||||
return response.json({"error": "MEMORY_NOT_READY"}, status=503)
|
||||
|
||||
membership = mem.membership.state
|
||||
n_est = membership.n_estimate()
|
||||
active_all = membership.active_members(include_islands=True)
|
||||
active_filtered = membership.active_members(include_islands=False)
|
||||
islands = [m for m in active_all if membership.reachability_ratio(m['node_id']) < dht_config.default_q]
|
||||
|
||||
# Обогащение из БД (версии, роли, public_host)
|
||||
db = request.ctx.db_session
|
||||
known = (await db.execute(select(KnownNode))).scalars().all()
|
||||
meta_by_pub = {r.public_key: (r, r.meta or {}) for r in known}
|
||||
meta_by_host = {r.ip: (r, r.meta or {}) for r in known}
|
||||
|
||||
# Precompute receipts stats per node
|
||||
receipts_elements = membership.receipts.elements() if hasattr(membership, 'receipts') else {}
|
||||
receipts_by_target: Dict[str, Dict[str, Any]] = {}
|
||||
for _rid, rec in receipts_elements.items():
|
||||
tid = str(rec.get('target_id'))
|
||||
if not tid:
|
||||
continue
|
||||
bucket = receipts_by_target.setdefault(tid, { 'total': 0, 'asn_set': set() })
|
||||
bucket['total'] += 1
|
||||
if rec.get('asn') is not None:
|
||||
try:
|
||||
bucket['asn_set'].add(int(rec.get('asn')))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _enrich(member: dict) -> dict:
|
||||
pub = str(member.get('public_key') or '')
|
||||
host = str(member.get('ip') or '')
|
||||
row_meta = (meta_by_pub.get(pub) or meta_by_host.get(host) or (None, {}))[1]
|
||||
caps = (member.get('meta') or {}).get('capabilities') or {}
|
||||
rec_stat = receipts_by_target.get(member.get('node_id') or '', {'total': 0, 'asn_set': set()})
|
||||
return {
|
||||
'node_id': member.get('node_id'),
|
||||
'public_key': pub or None,
|
||||
'public_host': row_meta.get('public_host'),
|
||||
'version': row_meta.get('version'),
|
||||
'role': row_meta.get('role') or 'read-only',
|
||||
'ip': host or None,
|
||||
'asn': member.get('asn'),
|
||||
'ip_first_octet': member.get('ip_first_octet'),
|
||||
'reachability_ratio': membership.reachability_ratio(member.get('node_id')),
|
||||
'last_update': member.get('last_update'),
|
||||
'accepts_inbound': bool(caps.get('accepts_inbound')),
|
||||
'is_bootstrap': bool(caps.get('is_bootstrap')),
|
||||
'receipts_total': int(rec_stat.get('total') or 0),
|
||||
'receipts_asn_unique': len(rec_stat.get('asn_set') or ()),
|
||||
}
|
||||
|
||||
members_payload = [_enrich(m) for m in active_all]
|
||||
# Server-side pagination
|
||||
try:
|
||||
page = max(1, int(request.args.get('page') or 1))
|
||||
except Exception:
|
||||
page = 1
|
||||
try:
|
||||
page_size = max(1, min(500, int(request.args.get('page_size') or 100)))
|
||||
except Exception:
|
||||
page_size = 100
|
||||
total_members = len(members_payload)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
members_page = members_payload[start:end]
|
||||
|
||||
# Агрегация репликаций по снимку DHT
|
||||
snapshot = mem.dht_store.snapshot() if hasattr(mem, 'dht_store') else {}
|
||||
per_node = {}
|
||||
conflict_under = 0
|
||||
conflict_over = 0
|
||||
for fp, rec in snapshot.items():
|
||||
key = rec.get('key') or ''
|
||||
if not key.startswith('meta:'):
|
||||
continue
|
||||
value = rec.get('value') or {}
|
||||
content_id = value.get('content_id')
|
||||
leases = (value.get('replica_leases') or {}).values()
|
||||
leader = value.get('leader')
|
||||
# Конфликты
|
||||
for ev in value.get('conflict_log') or []:
|
||||
t = (ev.get('type') or '').upper()
|
||||
if t == 'UNDER_REPLICATED':
|
||||
conflict_under += 1
|
||||
elif t == 'OVER_REPLICATED':
|
||||
conflict_over += 1
|
||||
# Пер-нодовые конфликты
|
||||
nid = ev.get('node_id')
|
||||
if nid:
|
||||
p = per_node.setdefault(nid, {'leases_held': 0, 'leaderships': 0, 'sample_contents': [], 'conflicts': {'over': 0, 'lease_expired': 0}, 'conflict_samples': []})
|
||||
if t == 'OVER_REPLICATED':
|
||||
p['conflicts']['over'] = p['conflicts'].get('over', 0) + 1
|
||||
elif t == 'LEASE_EXPIRED':
|
||||
p['conflicts']['lease_expired'] = p['conflicts'].get('lease_expired', 0) + 1
|
||||
if content_id and len(p['conflict_samples']) < 10:
|
||||
p['conflict_samples'].append({'content_id': content_id, 'type': t, 'ts': ev.get('ts')})
|
||||
# Лизы
|
||||
for l in leases:
|
||||
nid = l.get('node_id')
|
||||
if not nid:
|
||||
continue
|
||||
p = per_node.setdefault(nid, {'leases_held': 0, 'leaderships': 0, 'sample_contents': [], 'conflicts': {'over': 0, 'lease_expired': 0}, 'conflict_samples': []})
|
||||
p['leases_held'] += 1
|
||||
if content_id and len(p['sample_contents']) < 5:
|
||||
p['sample_contents'].append(content_id)
|
||||
if leader:
|
||||
p = per_node.setdefault(leader, {'leases_held': 0, 'leaderships': 0, 'sample_contents': [], 'conflicts': {'over': 0, 'lease_expired': 0}, 'conflict_samples': []})
|
||||
p['leaderships'] += 1
|
||||
|
||||
# Добавим trusted-only n_estimate и показатели активности в summary
|
||||
# Соберём allowed_nodes так же, как в репликации
|
||||
from app.core._utils.b58 import b58decode
|
||||
from app.core.network.dht.crypto import compute_node_id
|
||||
allowed_nodes = set()
|
||||
for row, meta in meta_by_pub.values():
|
||||
try:
|
||||
if (meta or {}).get('role') == 'trusted' and row.public_key:
|
||||
allowed_nodes.add(compute_node_id(b58decode(row.public_key)))
|
||||
except Exception:
|
||||
pass
|
||||
allowed_nodes.add(mem.node_id)
|
||||
n_est_trusted = membership.n_estimate_trusted(allowed_nodes) if hasattr(membership, 'n_estimate_trusted') else n_est
|
||||
# Активные trusted: те, кто в allowed_nodes и проходят TTL/Q
|
||||
active_trusted = [m for m in active_filtered if m.get('node_id') in allowed_nodes]
|
||||
# Экспортируем конфиг интервалов
|
||||
from app.core.network.dht import dht_config
|
||||
|
||||
# Build receipts report with validation status
|
||||
receipts_raw = (membership.receipts.elements() if hasattr(membership, 'receipts') else {}) or {}
|
||||
receipts: List[Dict[str, Any]] = []
|
||||
members_map = membership.members.elements() if hasattr(membership, 'members') else {}
|
||||
for _rid, entry in receipts_raw.items():
|
||||
target_id = str(entry.get('target_id'))
|
||||
issuer_id = str(entry.get('issuer_id'))
|
||||
asn = entry.get('asn')
|
||||
timestamp = entry.get('timestamp')
|
||||
signature = str(entry.get('signature') or '')
|
||||
status = 'unknown'
|
||||
# verify if possible
|
||||
issuer_pub = None
|
||||
for mid, mdata in members_map.items():
|
||||
if mid == issuer_id:
|
||||
issuer_pub = mdata.get('public_key')
|
||||
break
|
||||
if issuer_pub:
|
||||
try:
|
||||
from app.core._utils.b58 import b58decode as _b58d
|
||||
from app.core.network.dht.crypto import compute_node_id
|
||||
import nacl.signing # type: ignore
|
||||
# node_id/pubkey match
|
||||
if compute_node_id(_b58d(issuer_pub)) != issuer_id:
|
||||
status = 'mismatch_node_id'
|
||||
else:
|
||||
payload = {
|
||||
'schema_version': dht_config.schema_version,
|
||||
'target_id': target_id,
|
||||
'issuer_id': issuer_id,
|
||||
'asn': int(asn) if asn is not None else None,
|
||||
'timestamp': float(timestamp or 0),
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
vk = nacl.signing.VerifyKey(_b58d(issuer_pub))
|
||||
vk.verify(blob, _b58d(signature))
|
||||
status = 'valid'
|
||||
except Exception:
|
||||
status = 'bad_signature'
|
||||
else:
|
||||
status = 'unknown_issuer'
|
||||
receipts.append({
|
||||
'target_id': target_id,
|
||||
'issuer_id': issuer_id,
|
||||
'asn': asn,
|
||||
'timestamp': timestamp,
|
||||
'status': status,
|
||||
})
|
||||
|
||||
return response.json({
|
||||
'summary': {
|
||||
'n_estimate': n_est,
|
||||
'n_estimate_trusted': n_est_trusted,
|
||||
'active_trusted': len(active_trusted),
|
||||
'members_total': len(active_all),
|
||||
'active': len(active_filtered),
|
||||
'islands': len(islands),
|
||||
'replication_conflicts': {
|
||||
'under': conflict_under,
|
||||
'over': conflict_over,
|
||||
},
|
||||
'config': {
|
||||
'heartbeat_interval': dht_config.heartbeat_interval,
|
||||
'lease_ttl': dht_config.lease_ttl,
|
||||
'gossip_interval_sec': dht_config.gossip_interval_sec,
|
||||
'gossip_backoff_base_sec': dht_config.gossip_backoff_base_sec,
|
||||
'gossip_backoff_cap_sec': dht_config.gossip_backoff_cap_sec,
|
||||
}
|
||||
},
|
||||
'members': members_page,
|
||||
'per_node_replication': per_node,
|
||||
'receipts': receipts,
|
||||
'paging': { 'page': page, 'page_size': page_size, 'total': total_members },
|
||||
})
|
||||
|
||||
|
||||
async def s_api_v1_admin_network_config(request):
|
||||
if (unauth := _ensure_admin(request)):
|
||||
return unauth
|
||||
cfg = dht_config
|
||||
async with request.ctx.db_session() as session:
|
||||
sc = ServiceConfig(session)
|
||||
out = {
|
||||
'heartbeat_interval': cfg.heartbeat_interval,
|
||||
'lease_ttl': cfg.lease_ttl,
|
||||
'gossip_interval_sec': cfg.gossip_interval_sec,
|
||||
'gossip_backoff_base_sec': cfg.gossip_backoff_base_sec,
|
||||
'gossip_backoff_cap_sec': cfg.gossip_backoff_cap_sec,
|
||||
}
|
||||
# include overrides if present
|
||||
for k in list(out.keys()):
|
||||
ov = await sc.get(f'DHT_{k.upper()}', None)
|
||||
if ov is not None:
|
||||
out[k] = int(ov)
|
||||
return response.json({'ok': True, 'config': out})
|
||||
|
||||
|
||||
async def s_api_v1_admin_network_config_set(request):
|
||||
if (unauth := _ensure_admin(request)):
|
||||
return unauth
|
||||
data = request.json or {}
|
||||
allowed = {
|
||||
'heartbeat_interval': (5, 3600),
|
||||
'lease_ttl': (60, 86400),
|
||||
'gossip_interval_sec': (5, 600),
|
||||
'gossip_backoff_base_sec': (1, 300),
|
||||
'gossip_backoff_cap_sec': (10, 7200),
|
||||
}
|
||||
updates = {}
|
||||
for key, (lo, hi) in allowed.items():
|
||||
if key in data:
|
||||
try:
|
||||
val = int(data[key])
|
||||
except Exception:
|
||||
return response.json({'error': f'BAD_{key.upper()}'}, status=400)
|
||||
if val < lo or val > hi:
|
||||
return response.json({'error': f'RANGE_{key.upper()}', 'min': lo, 'max': hi}, status=400)
|
||||
updates[key] = val
|
||||
async with request.ctx.db_session() as session:
|
||||
sc = ServiceConfig(session)
|
||||
for key, val in updates.items():
|
||||
await sc.set(f'DHT_{key.upper()}', val)
|
||||
return response.json({'ok': True, 'updated': updates})
|
||||
|
||||
|
||||
async def s_api_v1_admin_sync_setlimits(request):
|
||||
if (unauth := _ensure_admin(request)):
|
||||
return unauth
|
||||
|
||||
+76
-15
@@ -1,3 +1,4 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
from sanic import response
|
||||
from sqlalchemy import select, and_, func, or_
|
||||
@@ -86,7 +87,24 @@ async def s_api_v1_content_view(request, content_address: str):
|
||||
'content_type': content_type,
|
||||
'content_mime': ctype,
|
||||
}
|
||||
content = await open_content_async(request.ctx.db_session, r_content)
|
||||
try:
|
||||
content = await open_content_async(request.ctx.db_session, r_content)
|
||||
except AssertionError:
|
||||
# Fallback: handle plain stored content without encrypted/decrypted pairing
|
||||
sc = r_content
|
||||
from mimetypes import guess_type as _guess
|
||||
_mime, _ = _guess(sc.filename or '')
|
||||
_mime = _mime or 'application/octet-stream'
|
||||
try:
|
||||
_ctype = _mime.split('/')[0]
|
||||
except Exception:
|
||||
_ctype = 'application'
|
||||
content = {
|
||||
'encrypted_content': sc,
|
||||
'decrypted_content': sc,
|
||||
'content_type': _ctype,
|
||||
'content_mime': _mime,
|
||||
}
|
||||
|
||||
master_address = content['encrypted_content'].meta.get('item_address', '')
|
||||
opts = {
|
||||
@@ -222,6 +240,16 @@ async def s_api_v1_content_view(request, content_address: str):
|
||||
or opts.get('content_mime')
|
||||
or 'application/octet-stream'
|
||||
)
|
||||
# Fallback: if stored content reports generic application/*, try guess by filename
|
||||
try:
|
||||
if content_mime.startswith('application/'):
|
||||
from mimetypes import guess_type as _guess
|
||||
_fn = decrypted_json.get('filename') or encrypted_json.get('filename') or ''
|
||||
_gm, _ = _guess(_fn)
|
||||
if _gm:
|
||||
content_mime = _gm
|
||||
except Exception:
|
||||
pass
|
||||
opts['content_mime'] = content_mime
|
||||
try:
|
||||
opts['content_type'] = content_mime.split('/')[0]
|
||||
@@ -249,7 +277,7 @@ async def s_api_v1_content_view(request, content_address: str):
|
||||
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}"
|
||||
return file_hash, f"{PROJECT_HOST}/api/v1/storage.proxy/{file_hash}"
|
||||
|
||||
has_preview = bool(derivative_latest.get('decrypted_preview') or converted_meta_map.get('low_preview'))
|
||||
display_options['has_preview'] = has_preview
|
||||
@@ -270,10 +298,28 @@ async def s_api_v1_content_view(request, content_address: str):
|
||||
chosen_row = derivative_latest[key]
|
||||
break
|
||||
|
||||
def _make_token_for(hash_value: str, scope: str, user_id: int | None) -> str:
|
||||
try:
|
||||
from app.core._crypto.signer import Signer
|
||||
from app.core._secrets import hot_seed, hot_pubkey
|
||||
from app.core._utils.b58 import b58encode as _b58e
|
||||
import time, json
|
||||
signer = Signer(hot_seed)
|
||||
exp = int(time.time()) + 600
|
||||
uid = int(user_id or 0)
|
||||
payload = {'hash': hash_value, 'scope': scope, 'exp': exp, 'uid': uid}
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
sig = signer.sign(blob)
|
||||
pub = _b58e(hot_pubkey).decode()
|
||||
return f"pub={pub}&exp={exp}&scope={scope}&uid={uid}&sig={sig}"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if chosen_row:
|
||||
file_hash, url = _row_to_hash_and_url(chosen_row)
|
||||
if url:
|
||||
display_options['content_url'] = url
|
||||
token = _make_token_for(file_hash or '', 'full' if have_access else 'preview', getattr(request.ctx.user, 'id', None))
|
||||
display_options['content_url'] = f"{url}?{token}" if token else url
|
||||
ext_candidate = None
|
||||
if chosen_row.content_type:
|
||||
ext_candidate = chosen_row.content_type.split('/')[-1]
|
||||
@@ -298,17 +344,31 @@ async def s_api_v1_content_view(request, content_address: str):
|
||||
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
|
||||
filename = stored.filename or ''
|
||||
if '.' in filename:
|
||||
opts['content_ext'] = filename.split('.')[-1]
|
||||
elif '/' in content_mime:
|
||||
opts['content_ext'] = content_mime.split('/')[-1]
|
||||
if content_kind == 'binary':
|
||||
display_options['original_available'] = True
|
||||
break
|
||||
# Пробуем сразу через прокси (даже если локальной записи нет)
|
||||
token = _make_token_for(hash_value, 'full' if have_access else 'preview', getattr(request.ctx.user, 'id', None))
|
||||
display_options['content_url'] = f"{PROJECT_HOST}/api/v1/storage.proxy/{hash_value}?{token}" if token else f"{PROJECT_HOST}/api/v1/storage.proxy/{hash_value}"
|
||||
if '/' in content_mime:
|
||||
opts['content_ext'] = content_mime.split('/')[-1]
|
||||
if content_kind == 'binary':
|
||||
display_options['original_available'] = True
|
||||
break
|
||||
|
||||
# Final fallback: no derivatives known — serve stored content directly for AV
|
||||
if not display_options['content_url'] and content_kind in ('audio', 'video'):
|
||||
from app.core._utils.b58 import b58encode as _b58e
|
||||
scid = decrypted_json.get('cid') or encrypted_json.get('cid')
|
||||
try:
|
||||
from app.core.content.content_id import ContentId as _CID
|
||||
if scid:
|
||||
_cid = _CID.deserialize(scid)
|
||||
h = _cid.content_hash_b58
|
||||
else:
|
||||
h = decrypted_json.get('hash')
|
||||
except Exception:
|
||||
h = decrypted_json.get('hash')
|
||||
if h:
|
||||
token = _make_token_for(h, 'preview' if not have_access else 'full', getattr(request.ctx.user, 'id', None))
|
||||
display_options['content_url'] = f"{PROJECT_HOST}/api/v1/storage.proxy/{h}?{token}" if token else f"{PROJECT_HOST}/api/v1/storage.proxy/{h}"
|
||||
|
||||
# Metadata fallback
|
||||
content_meta = encrypted_json
|
||||
@@ -334,7 +394,8 @@ async def s_api_v1_content_view(request, content_address: str):
|
||||
}
|
||||
cover_cid = content_meta.get('cover_cid')
|
||||
if cover_cid:
|
||||
content_metadata_json.setdefault('image', f"{PROJECT_HOST}/api/v1.5/storage/{cover_cid}")
|
||||
token = _make_token_for(cover_cid, 'preview', getattr(request.ctx.user, 'id', None))
|
||||
content_metadata_json.setdefault('image', f"{PROJECT_HOST}/api/v1/storage.proxy/{cover_cid}?{token}" if token else f"{PROJECT_HOST}/api/v1/storage.proxy/{cover_cid}")
|
||||
|
||||
display_options['metadata'] = content_metadata_json
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sanic import response
|
||||
|
||||
from app.core.logger import make_log
|
||||
from app.core._utils.b58 import b58decode
|
||||
from app.core.network.dht.records import DHTRecord
|
||||
from app.core.network.dht.store import DHTStore
|
||||
from app.core.network.dht.crypto import compute_node_id
|
||||
from app.core.network.dht.keys import MetaKey, MembershipKey, MetricKey
|
||||
from sqlalchemy import select
|
||||
from app.core.models.my_network import KnownNode
|
||||
|
||||
|
||||
def _merge_strategy_for(key: str):
|
||||
# Выбираем правильную стратегию merge по префиксу ключа
|
||||
from app.core.network.dht.replication import ReplicationState
|
||||
from app.core.network.dht.membership import MembershipState
|
||||
from app.core.network.dht.metrics import ContentMetricsState
|
||||
if key.startswith('meta:'):
|
||||
return lambda a, b: ReplicationState.from_dict(a).merge_with(ReplicationState.from_dict(b)).to_dict()
|
||||
if key.startswith('membership:'):
|
||||
# Для membership нужен node_id, но это только для локального состояния; здесь достаточно CRDT-мерджа
|
||||
return lambda a, b: MembershipState.from_dict('remote', None, a).merge(MembershipState.from_dict('remote', None, b)).to_dict()
|
||||
if key.startswith('metric:'):
|
||||
return lambda a, b: ContentMetricsState.from_dict('remote', a).merge(ContentMetricsState.from_dict('remote', b)).to_dict()
|
||||
return lambda a, b: b
|
||||
|
||||
|
||||
async def s_api_v1_dht_get(request):
|
||||
"""Возвращает запись DHT по fingerprint или key."""
|
||||
store: DHTStore = request.app.ctx.memory.dht_store
|
||||
fp = request.args.get('fingerprint')
|
||||
key = request.args.get('key')
|
||||
if fp:
|
||||
rec = store.get(fp)
|
||||
if not rec:
|
||||
return response.json({'error': 'NOT_FOUND'}, status=404)
|
||||
return response.json({**rec.to_payload(), 'signature': rec.signature})
|
||||
if key:
|
||||
snap = store.snapshot()
|
||||
for _fp, payload in snap.items():
|
||||
if payload.get('key') == key:
|
||||
return response.json(payload)
|
||||
return response.json({'error': 'NOT_FOUND'}, status=404)
|
||||
return response.json({'error': 'BAD_REQUEST'}, status=400)
|
||||
|
||||
|
||||
def _verify_publisher(node_id: str, public_key_b58: str) -> bool:
|
||||
try:
|
||||
derived = compute_node_id(b58decode(public_key_b58))
|
||||
return derived == node_id
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def s_api_v1_dht_put(request):
|
||||
"""Принимает запись(и) DHT, проверяет подпись и выполняет merge/persist.
|
||||
|
||||
Поддерживает одиночную запись (record: {...}) и пакет (records: [{...}]).
|
||||
Требует поле public_key отправителя и соответствие node_id.
|
||||
"""
|
||||
mem = request.app.ctx.memory
|
||||
store: DHTStore = mem.dht_store
|
||||
data = request.json or {}
|
||||
public_key = data.get('public_key')
|
||||
if not public_key:
|
||||
return response.json({'error': 'MISSING_PUBLIC_KEY'}, status=400)
|
||||
|
||||
# Determine publisher role (trusted/read-only/deny)
|
||||
role = None
|
||||
try:
|
||||
session = request.ctx.db_session
|
||||
kn = (await session.execute(select(KnownNode).where(KnownNode.public_key == public_key))).scalars().first()
|
||||
role = (kn.meta or {}).get('role') if kn and kn.meta else None
|
||||
except Exception:
|
||||
role = None
|
||||
|
||||
def _process_one(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
rec = DHTRecord.create(
|
||||
key=payload['key'],
|
||||
fingerprint=payload['fingerprint'],
|
||||
value=payload['value'],
|
||||
node_id=payload['node_id'],
|
||||
logical_counter=int(payload['logical_counter']),
|
||||
signature=payload.get('signature'),
|
||||
timestamp=float(payload.get('timestamp') or 0),
|
||||
)
|
||||
except Exception as e:
|
||||
return {'error': f'BAD_RECORD: {e}'}
|
||||
if not _verify_publisher(rec.node_id, public_key):
|
||||
return {'error': 'NODE_ID_MISMATCH'}
|
||||
# Подтверждение подписи записи
|
||||
if not rec.verify(public_key):
|
||||
return {'error': 'BAD_SIGNATURE'}
|
||||
# Enforce ACL: untrusted nodes may not mutate meta/metric records
|
||||
if role != 'trusted':
|
||||
if rec.key.startswith('meta:') or rec.key.startswith('metric:'):
|
||||
return {'error': 'FORBIDDEN_NOT_TRUSTED'}
|
||||
merge_fn = _merge_strategy_for(rec.key)
|
||||
try:
|
||||
merged = store.merge_record(rec, merge_fn)
|
||||
return {'ok': True, 'fingerprint': merged.fingerprint}
|
||||
except Exception as e:
|
||||
make_log('DHT.put', f'merge failed: {e}', level='warning')
|
||||
return {'error': 'MERGE_FAILED'}
|
||||
|
||||
if 'record' in data:
|
||||
result = _process_one(data['record'])
|
||||
status = 200 if 'ok' in result else 400
|
||||
return response.json(result, status=status)
|
||||
elif 'records' in data and isinstance(data['records'], list):
|
||||
results: List[Dict[str, Any]] = []
|
||||
ok = True
|
||||
for item in data['records']:
|
||||
res = _process_one(item)
|
||||
if 'error' in res:
|
||||
ok = False
|
||||
results.append(res)
|
||||
return response.json({'ok': ok, 'results': results}, status=200 if ok else 207)
|
||||
return response.json({'error': 'BAD_REQUEST'}, status=400)
|
||||
+48
-14
@@ -109,6 +109,17 @@ async def s_api_v1_network_handshake(request):
|
||||
if not data.get("nonce") or not check_and_remember_nonce(request.app.ctx.memory, data.get("public_key"), data.get("nonce")):
|
||||
return response.json({"error": "NONCE_REPLAY"}, status=400)
|
||||
|
||||
# Base schema and identity checks
|
||||
if data.get("schema_version") != dht_config.schema_version:
|
||||
return response.json({"error": "UNSUPPORTED_SCHEMA_VERSION"}, status=400)
|
||||
|
||||
try:
|
||||
expected_node_id = compute_node_id(b58decode(data["public_key"]))
|
||||
except Exception:
|
||||
return response.json({"error": "BAD_PUBLIC_KEY"}, status=400)
|
||||
if data.get("node_id") != expected_node_id:
|
||||
return response.json({"error": "NODE_ID_MISMATCH"}, status=400)
|
||||
|
||||
peer_version = str(data.get("version"))
|
||||
ipfs_meta = _extract_ipfs_meta(data.get("ipfs") or {})
|
||||
comp = compatibility(peer_version, CURRENT_PROTOCOL_VERSION)
|
||||
@@ -160,9 +171,10 @@ async def s_api_v1_network_handshake(request):
|
||||
membership_mgr = getattr(request.app.ctx.memory, "membership", None)
|
||||
if membership_mgr:
|
||||
remote_ip = (request.headers.get('X-Forwarded-For') or request.remote_addr or request.ip or '').split(',')[0].strip() or None
|
||||
# Determine caller ASN using advertised value or resolver
|
||||
remote_asn = data.get("asn")
|
||||
if remote_asn is None:
|
||||
remote_asn = asn_resolver.resolve(remote_ip)
|
||||
remote_asn = await asn_resolver.resolve_async(remote_ip, request.ctx.db_session)
|
||||
else:
|
||||
if remote_ip:
|
||||
asn_resolver.learn(remote_ip, int(remote_asn))
|
||||
@@ -181,15 +193,42 @@ async def s_api_v1_network_handshake(request):
|
||||
if not receipt.get("target_id") or not receipt.get("issuer_id"):
|
||||
continue
|
||||
try:
|
||||
membership_mgr.record_receipt(
|
||||
ReachabilityReceipt(
|
||||
target_id=str(receipt.get("target_id")),
|
||||
issuer_id=str(receipt.get("issuer_id")),
|
||||
asn=int(receipt["asn"]) if receipt.get("asn") is not None else None,
|
||||
timestamp=float(receipt.get("timestamp", data.get("timestamp"))),
|
||||
signature=str(receipt.get("signature", "")),
|
||||
# Only accept receipts issued by the caller
|
||||
issuer_id = str(receipt.get("issuer_id"))
|
||||
if issuer_id != data["node_id"]:
|
||||
continue
|
||||
# Canonical message for receipt verification
|
||||
# schema_version is embedded to avoid replay across versions
|
||||
rec_asn = receipt.get("asn")
|
||||
if rec_asn is None:
|
||||
rec_asn = remote_asn
|
||||
payload = {
|
||||
"schema_version": dht_config.schema_version,
|
||||
"target_id": str(receipt.get("target_id")),
|
||||
"issuer_id": issuer_id,
|
||||
"asn": int(rec_asn) if rec_asn is not None else None,
|
||||
"timestamp": float(receipt.get("timestamp", data.get("timestamp"))),
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
try:
|
||||
import nacl.signing # type: ignore
|
||||
from app.core._utils.b58 import b58decode as _b58d
|
||||
vk = nacl.signing.VerifyKey(_b58d(data["public_key"]))
|
||||
sig_b = _b58d(str(receipt.get("signature", "")))
|
||||
vk.verify(blob, sig_b)
|
||||
# Accept and persist
|
||||
membership_mgr.record_receipt(
|
||||
ReachabilityReceipt(
|
||||
target_id=payload["target_id"],
|
||||
issuer_id=payload["issuer_id"],
|
||||
asn=payload["asn"],
|
||||
timestamp=payload["timestamp"],
|
||||
signature=str(receipt.get("signature", "")),
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Ignore invalid receipts
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
@@ -271,8 +310,3 @@ async def s_api_v1_network_handshake(request):
|
||||
status = 200
|
||||
resp["warning"] = "MINOR version differs; proceed with caution"
|
||||
return response.json(resp, status=status)
|
||||
if data.get("schema_version") != dht_config.schema_version:
|
||||
return response.json({"error": "UNSUPPORTED_SCHEMA_VERSION"}, status=400)
|
||||
expected_node_id = compute_node_id(b58decode(data["public_key"]))
|
||||
if data.get("node_id") != expected_node_id:
|
||||
return response.json({"error": "NODE_ID_MISMATCH"}, status=400)
|
||||
@@ -16,6 +16,14 @@ from app.core.models.node_storage import StoredContent
|
||||
from app.core._config import UPLOADS_DIR
|
||||
from app.core.models.content_v3 import ContentDerivative
|
||||
from app.core._utils.resolve_content import resolve_content
|
||||
from app.core.network.nodesig import verify_request
|
||||
from app.core.models.my_network import KnownNode
|
||||
from sqlalchemy import select as sa_select
|
||||
import httpx
|
||||
from app.core._crypto.signer import Signer
|
||||
from app.core._secrets import hot_seed
|
||||
from app.core._utils.b58 import b58encode as _b58e, b58decode as _b58d
|
||||
import json, time
|
||||
|
||||
|
||||
# POST /api/v1.5/storage
|
||||
@@ -305,3 +313,125 @@ async def s_api_v1_5_storage_get(request, file_hash):
|
||||
else:
|
||||
make_log("uploader_v1.5", f"Returning full file for video/audio: {final_path}", level="INFO")
|
||||
return await response.file(final_path, mime_type=mime_type)
|
||||
|
||||
|
||||
# GET /api/v1/storage.fetch/<file_hash>
|
||||
# Внутренний эндпойнт для межузлового запроса (NodeSig). Возвращает файл, если он есть локально.
|
||||
async def s_api_v1_storage_fetch(request, file_hash):
|
||||
ok, node_id, reason = verify_request(request, request.app.ctx.memory)
|
||||
if not ok:
|
||||
return response.json({"error": reason or "UNAUTHORIZED"}, status=401)
|
||||
# Только доверенные узлы
|
||||
try:
|
||||
session = request.ctx.db_session
|
||||
row = (await session.execute(sa_select(KnownNode).where(KnownNode.public_key == node_id))).scalars().first()
|
||||
role = (row.meta or {}).get('role') if row and row.meta else None
|
||||
if role != 'trusted':
|
||||
return response.json({"error": "DENIED_NOT_TRUSTED"}, status=403)
|
||||
except Exception:
|
||||
pass
|
||||
# Переиспользуем реализацию v1.5
|
||||
return await s_api_v1_5_storage_get(request, file_hash)
|
||||
|
||||
|
||||
# GET /api/v1/storage.proxy/<file_hash>
|
||||
# Проксирование для web-клиента: если локально нет файла, попытка получить у доверенных узлов по NodeSig
|
||||
async def s_api_v1_storage_proxy(request, file_hash):
|
||||
# Require either valid NodeSig (unlikely for public clients) or a signed access token
|
||||
# Token fields: pub, exp, scope, uid, sig over json {hash,scope,exp,uid}
|
||||
def _verify_access_token() -> bool:
|
||||
try:
|
||||
pub = (request.args.get('pub') or '').strip()
|
||||
exp = int(request.args.get('exp') or '0')
|
||||
scope = (request.args.get('scope') or '').strip()
|
||||
uid = int(request.args.get('uid') or '0')
|
||||
sig = (request.args.get('sig') or '').strip()
|
||||
if not pub or not exp or not scope or not sig:
|
||||
return False
|
||||
if exp < int(time.time()):
|
||||
return False
|
||||
payload = {
|
||||
'hash': file_hash,
|
||||
'scope': scope,
|
||||
'exp': exp,
|
||||
'uid': uid,
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
import nacl.signing
|
||||
vk = nacl.signing.VerifyKey(_b58d(pub))
|
||||
vk.verify(blob, _b58d(sig))
|
||||
# Note: we do not require a session-bound user for media fetches,
|
||||
# the short‑lived signature itself is sufficient.
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
ok_nodesig, _nid, _reason = verify_request(request, request.app.ctx.memory)
|
||||
if not ok_nodesig and not _verify_access_token():
|
||||
return response.json({'error': 'UNAUTHORIZED'}, status=401)
|
||||
# Сначала пробуем локально без возврата 404
|
||||
try:
|
||||
from base58 import b58encode as _b58e
|
||||
try:
|
||||
# Поддержка как хэша, так и CID
|
||||
from app.core._utils.resolve_content import resolve_content as _res
|
||||
cid, _ = _res(file_hash)
|
||||
file_hash = _b58e(cid.content_hash).decode()
|
||||
except Exception:
|
||||
pass
|
||||
final_path = os.path.join(UPLOADS_DIR, f"{file_hash}")
|
||||
if os.path.exists(final_path):
|
||||
return await s_api_v1_5_storage_get(request, file_hash)
|
||||
except Exception:
|
||||
pass
|
||||
# Локально нет — пробуем у доверенных
|
||||
try:
|
||||
async with request.app.ctx.memory.transaction("storage.proxy"):
|
||||
# Соберём список trusted узлов
|
||||
session = request.ctx.db_session
|
||||
nodes = (await session.execute(sa_select(KnownNode))).scalars().all()
|
||||
candidates = []
|
||||
for n in nodes:
|
||||
role = (n.meta or {}).get('role') if n.meta else None
|
||||
if role != 'trusted':
|
||||
continue
|
||||
host = (n.meta or {}).get('public_host') or (n.ip or '')
|
||||
if not host:
|
||||
continue
|
||||
base = host.rstrip('/')
|
||||
if not base.startswith('http'):
|
||||
base = f"http://{base}:{n.port or 80}"
|
||||
candidates.append(base)
|
||||
# Проксируем с передачей Range, стриминг
|
||||
range_header = request.headers.get("Range")
|
||||
timeout = httpx.Timeout(10.0, read=60.0)
|
||||
for base in candidates:
|
||||
url = f"{base}/api/v1/storage.fetch/{file_hash}"
|
||||
try:
|
||||
# Подпишем NodeSig
|
||||
from app.core._secrets import hot_seed, hot_pubkey
|
||||
from app.core.network.nodesig import sign_headers
|
||||
from app.core._utils.b58 import b58encode as _b58e
|
||||
pk_b58 = _b58e(hot_pubkey).decode()
|
||||
headers = sign_headers('GET', f"/api/v1/storage.fetch/{file_hash}", b"", hot_seed, pk_b58)
|
||||
if range_header:
|
||||
headers['Range'] = range_header
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.get(url, headers=headers)
|
||||
if r.status_code == 404:
|
||||
continue
|
||||
if r.status_code not in (200, 206):
|
||||
continue
|
||||
# Проксируем заголовки контента
|
||||
resp = await request.respond(status=r.status_code, headers={
|
||||
k: v for k, v in r.headers.items() if k.lower() in ("content-type", "content-length", "content-range", "accept-ranges")
|
||||
})
|
||||
async for chunk in r.aiter_bytes(chunk_size=1024*1024):
|
||||
await resp.send(chunk)
|
||||
await resp.eof()
|
||||
return resp
|
||||
except Exception as e:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return response.json({"error": "File not found"}, status=404)
|
||||
Reference in new issue
Block a user