""" app.py — Flask API rbxl-importer. Endpoints: POST /import/rbxl/analyze body: multipart, file=<.rbxl> resp: { "preview_hash": str, # для следующего шага "report": { "filename": str, "size_bytes": int, "version": int, "class_count": int, "instance_count": int, "top_classes": [{"class": str, "count": int}, ...], "scripts_total": int, "assets_to_download": int, "warnings": [str, ...] } } POST /import/rbxl/create body: { "preview_hash": str, "title": str, "auth_user_id": int } resp: { "project_id": int, "redirect": "/edit/" } — Парсит → конвертит → скачивает ассеты → создаёт запись в kubikon3d_projects. GET /health resp: { ok: true, version: "0.1.0" } Безопасность: эндпоинты доступны только МИНу (user_id=1). Проверка через X-User-Id header (NPM прокинет после JWT-проверки в user-сервисе). """ import os import sys import json import hashlib import tempfile import logging from pathlib import Path from flask import Flask, request, jsonify from flask_cors import CORS import psycopg2 from psycopg2.extras import RealDictCursor import redis sys.path.insert(0, os.path.dirname(__file__)) from rbxl_parser import parse, class_histogram from converter import Converter from asset_downloader import AssetDownloader, PendingDownload from mesh_converter import parse_roblox_mesh, build_glb, MeshParseError logging.basicConfig(level=logging.INFO) logger = logging.getLogger('rbxl-importer') PG_DSN = os.environ.get( 'PG_DSN', 'host=192.168.1.152 port=25435 user=min password=5cb157970ad5e3b2952ed05decaf1bab dbname=storys_db' ) REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') STORAGE_ROOT = os.environ.get('STORAGE_ROOT', '/opt/roblox-assets') PUBLIC_ASSET_BASE = os.environ.get('PUBLIC_ASSET_BASE', 'https://assets.rublox.pro/roblox') MAX_RBXL_SIZE = 50 * 1024 * 1024 # 50 MB app = Flask(__name__) # CORS открыт для всех источников — фронт студии живёт на studio.rublox.pro, # api-rbxl проксируется через NPM на minecraftia-school.ru/api-rbxl/*. # Поддерживаем preflight (OPTIONS) явно через after_request — иногда # flask-cors не отдавал заголовки для OPTIONS если NPM их перекрывал. CORS(app, resources={r'/*': {'origins': '*'}}, supports_credentials=False) @app.after_request def _add_cors_headers(resp): resp.headers['Access-Control-Allow-Origin'] = '*' resp.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' resp.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-User-Id, X-User-Login' resp.headers['Access-Control-Max-Age'] = '3600' return resp @app.route('/import/rbxl/analyze', methods=['OPTIONS']) @app.route('/import/rbxl/create', methods=['OPTIONS']) def _preflight(): return '', 204 # Devlog для удалённой отладки dev-сессий студии: фронт пушит сюда # console.error/warn, failed network requests, неожиданные exceptions. from app_devlog import devlog_bp app.register_blueprint(devlog_bp) try: rds = redis.from_url(REDIS_URL, decode_responses=False) rds.ping() logger.info(f'Redis connected: {REDIS_URL}') except Exception as e: logger.warning(f'Redis NOT connected: {e}; preview cache отключён') rds = None def pg_conn(): return psycopg2.connect(PG_DSN) def auth_check(req) -> int: """Возвращает user_id если ОК, иначе бросает RuntimeError.""" # X-User-Id выставляется upstream NPM или service-user после JWT-проверки. # В dev можно через header X-Auth-Override (только в LAN). user_id_str = req.headers.get('X-User-Id') or req.headers.get('X-Auth-Override') if not user_id_str: raise RuntimeError('No X-User-Id header') try: uid = int(user_id_str) except ValueError: raise RuntimeError(f'Bad X-User-Id: {user_id_str!r}') # Импорт открыт всем (см. вики «Импорт из Roblox»). return uid @app.errorhandler(Exception) def on_error(e): logger.exception('handler error') return jsonify({'error': str(e), 'type': type(e).__name__}), 500 @app.get('/health') def health(): return jsonify({'ok': True, 'version': '0.1.0', 'service': 'rbxl-importer'}) @app.post('/import/rbxl/analyze') def analyze(): try: user_id = auth_check(request) except RuntimeError as e: return jsonify({'error': str(e)}), 403 if 'file' not in request.files: return jsonify({'error': 'file field required'}), 400 upload = request.files['file'] blob = upload.read() if len(blob) > MAX_RBXL_SIZE: return jsonify({'error': f'file too large (> {MAX_RBXL_SIZE} bytes)'}), 413 # Авто-детект XML vs Binary формата. # Бинарный: ... stripped = blob.lstrip() is_binary = stripped.startswith(b' None: """Walks project_data, заменяет {rbxAssetId: X} → url из asset_map.""" scene = project_data.get('scene', {}) for glb in scene.get('glbModels', []): rid = glb.get('rbxAssetId') if rid in asset_map: glb['url'] = asset_map[rid] for snd in scene.get('sounds', []): rid = snd.get('rbxAssetId') if rid in asset_map: snd['url'] = asset_map[rid] def _apply_gui_mode(project_data: dict, mode: str) -> None: """Фильтрует scene.gui[] по режиму. 'all' — оставить всё (default). 'screen-only' — оставить только ScreenGui-HUD, удалить billboard/surface. Карты с 200+ BillboardGui (Robloxity) перестают тормозить. 'skip' — удалить gui[] совсем. """ scene = project_data.get('scene', {}) if mode == 'skip': scene['gui'] = [] return if mode == 'screen-only': gui = scene.get('gui', []) scene['gui'] = [g for g in gui if g.get('gui_container_kind', 'screen') == 'screen'] return # 'all' — без изменений def _apply_scripts_mode(project_data: dict, mode: str) -> None: """Применяет режим scripts_mode к проекту. mode='disabled' (default): для каждого скрипта меняем JSON-метадату на 2-й строке packed-кода — выставляем enabled=False. GameRuntime уже умеет уважать этот флаг и не запускает. mode='enabled': оставляем как было (как пришло из конвертера). mode='skip': удаляем все scripts из scene.scripts полностью. """ scene = project_data.get('scene', {}) scripts = scene.get('scripts', []) if not scripts: return if mode == 'skip': scene['scripts'] = [] return if mode == 'enabled': return # ничего не делаем # mode == 'disabled' — патчим метадату каждого скрипта. # Формат packed-кода (см. converter._convert_script): # "// @roblox-lua\n// {JSON}\n/* lua_source:\n...source...\n*/\n" for s in scripts: code = s.get('code', '') lines = code.split('\n', 2) if len(lines) < 2 or not lines[0].startswith('// @roblox-lua'): continue meta_line = lines[1] if not meta_line.startswith('// '): continue try: meta = json.loads(meta_line[3:]) meta['enabled'] = False new_meta_line = '// ' + json.dumps(meta, ensure_ascii=False) s['code'] = lines[0] + '\n' + new_meta_line + '\n' + (lines[2] if len(lines) > 2 else '') except (json.JSONDecodeError, ValueError): continue if __name__ == '__main__': app.run(host='0.0.0.0', port=8690, debug=False)