feat: implement Layer 2 per-file transit, RAM session, mock buffer, and tests (v0.2.0)
This commit is contained in:
+115
-13
@@ -1,48 +1,150 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from flask import Flask, render_template, send_from_directory
|
||||
|
||||
from flask import Blueprint, Flask, Response, jsonify, render_template, request, send_from_directory
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
# Конфигурация демо содержит только разрешённые расширения для browser picker.
|
||||
DEFAULT_CONFIG = {"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"]}
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from upload.backend.upload_refs import create_upload_refs_blueprint
|
||||
from upload.backend.session import get_files, file_count, cleanup
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
log = logging.getLogger("upload-platform")
|
||||
|
||||
# Конфигурация стенда: разрешённые расширения, буфер и лимиты
|
||||
DEFAULT_CONFIG = {
|
||||
"allowedExt": [".pdf", ".doc", ".docx", ".txt", ".md"],
|
||||
"vmUploadUrl": "/mock-buffer/",
|
||||
"vmUploadPrefix": "/mock-buffer/",
|
||||
}
|
||||
try:
|
||||
with (ROOT / "config.json").open(encoding="utf-8") as config_file:
|
||||
CONFIG = json.load(config_file)
|
||||
CONFIG = {**DEFAULT_CONFIG, **json.load(config_file)}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
CONFIG = DEFAULT_CONFIG
|
||||
|
||||
VERSION = "0.2.0"
|
||||
|
||||
VERSION = "0.1.13"
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=str(ROOT / "site" / "templates"),
|
||||
static_folder=str(ROOT / "site" / "static"),
|
||||
)
|
||||
|
||||
# Flask нужен здесь только как статический сервер HTML, CSS и ES-модулей.
|
||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||
# -----------------------------------------------------------------------------
|
||||
# In-memory Mock Buffer (эмуляция WebDAV буфера на ВМ для локальных тестов)
|
||||
# -----------------------------------------------------------------------------
|
||||
_mock_storage = {}
|
||||
_mock_storage_lock = threading.Lock()
|
||||
mock_buffer_bp = Blueprint("mock_buffer", __name__)
|
||||
|
||||
|
||||
@mock_buffer_bp.route("/mock-buffer/<path:key>", methods=["PUT"])
|
||||
def mock_put(key):
|
||||
"""Принимает файл от браузера в RAM mock-буфера."""
|
||||
data = request.get_data()
|
||||
with _mock_storage_lock:
|
||||
_mock_storage[key] = data
|
||||
log.info("MockBuffer: stored key=%s (%d bytes)", key, len(data))
|
||||
return "", 201
|
||||
|
||||
|
||||
@mock_buffer_bp.route("/mock-buffer/<path:key>", methods=["GET"])
|
||||
def mock_get(key):
|
||||
"""Отдаёт файл по GET-запросу бэкенда (исходящий pull)."""
|
||||
with _mock_storage_lock:
|
||||
data = _mock_storage.get(key)
|
||||
if data is None:
|
||||
log.warning("MockBuffer: key not found key=%s", key)
|
||||
return "Not found", 404
|
||||
return Response(data, mimetype="application/octet-stream")
|
||||
|
||||
|
||||
@mock_buffer_bp.route("/mock-buffer/<path:key>", methods=["DELETE"])
|
||||
def mock_delete(key):
|
||||
"""Удаляет файл из RAM mock-буфера после успешного pull."""
|
||||
with _mock_storage_lock:
|
||||
existed = _mock_storage.pop(key, None) is not None
|
||||
log.info("MockBuffer: deleted key=%s (existed=%s)", key, existed)
|
||||
return "", 204
|
||||
|
||||
|
||||
@mock_buffer_bp.route("/mock-buffer/status", methods=["GET"])
|
||||
def mock_status():
|
||||
"""Диагностика mock-буфера: количество и размер находящихся в нём файлов."""
|
||||
with _mock_storage_lock:
|
||||
items = [{"key": k, "size": len(v)} for k, v in _mock_storage.items()]
|
||||
return jsonify({"count": len(items), "items": items})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Регистрация Слоя 2 (upload_refs) с эмуляцией передачи в Слой 3
|
||||
# -----------------------------------------------------------------------------
|
||||
def on_file_received_for_layer3(sid: str, name: str, content: bytes):
|
||||
"""Эмуляция отдачи файла в Слой 3 бизнес-логики (обфускация/парсинг)."""
|
||||
log.info("[Layer 3 Emulation] Ready to process file in session %s: name=%s, size=%d bytes",
|
||||
sid, name, len(content))
|
||||
|
||||
|
||||
upload_refs_bp = create_upload_refs_blueprint({
|
||||
"apiPrefix": "/api",
|
||||
"vmUploadPrefix": CONFIG.get("vmUploadPrefix", "/mock-buffer/"),
|
||||
"maxFileBytes": CONFIG.get("maxFileBytes", 50 * 1024 * 1024),
|
||||
"maxSessionBytes": CONFIG.get("maxSessionBytes", 500 * 1024 * 1024),
|
||||
"ttlSeconds": CONFIG.get("ttlSeconds", 30 * 60),
|
||||
"pullRetries": 2,
|
||||
"pullRetryDelay": 0.3,
|
||||
"onFileReceived": on_file_received_for_layer3,
|
||||
})
|
||||
|
||||
app.register_blueprint(mock_buffer_bp)
|
||||
app.register_blueprint(upload_refs_bp)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Роуты тестового стенда
|
||||
# -----------------------------------------------------------------------------
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""Возвращает demo-страницу и передаёт в неё версию и JSON-конфигурацию."""
|
||||
"""Возвращает demo-страницу со Слой 1 (picker) и Слой 2 (transit upload)."""
|
||||
return render_template("index.html", version=VERSION, config=CONFIG)
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Возвращает 200 для liveness-проверки платформы."""
|
||||
"""Liveness-проверка платформы."""
|
||||
return "ok", 200
|
||||
|
||||
|
||||
@app.route("/api/session/<sid>/files", methods=["GET"])
|
||||
def session_files(sid):
|
||||
"""Проверка содержимого сессии в RAM бэкенда (для тестов и UI)."""
|
||||
files = get_files(sid)
|
||||
if files is None:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"session": sid,
|
||||
"files": [{"name": name, "size": len(content)} for name, content in files]
|
||||
})
|
||||
|
||||
|
||||
@app.get("/upload-frontend/<path:filename>")
|
||||
def upload_frontend(filename):
|
||||
"""Отдаёт один frontend-файл из изолированного каталога upload/frontend."""
|
||||
"""Отдаёт исходные модули фронтенда из каталога upload/frontend."""
|
||||
return send_from_directory(ROOT / "upload" / "frontend", filename)
|
||||
|
||||
|
||||
@app.get("/file-picker/<path:filename>")
|
||||
def file_picker_dist(filename):
|
||||
"""Отдаёт собранный универсальный picker-бандл из каталога dist."""
|
||||
"""Отдаёт собранный бандл из каталога dist."""
|
||||
return send_from_directory(ROOT / "dist", filename)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=False, host="0.0.0.0", port=5000)
|
||||
app.run(debug=False, host="0.0.0.0", port=5000)
|
||||
|
||||
@@ -7,25 +7,114 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Основной контейнер демо: Flask только подставляет version, остальное работает в браузере. -->
|
||||
<!-- Основной контейнер стенда: Слой 1 (выбор) + Слой 2 (пофайловый транзит через RAM) -->
|
||||
<main class="page">
|
||||
<!-- Заголовок показывает назначение picker и текущую версию сборки. -->
|
||||
<header>
|
||||
<p class="eyebrow">FILE INTAKE / {{ version }}</p>
|
||||
<p class="eyebrow">FILE INTAKE & TRANSIT / {{ version }}</p>
|
||||
<h1>Загрузка документов</h1>
|
||||
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут раскрыты автоматически.</p>
|
||||
<p class="lede">Выберите отдельные файлы или целую папку. Архивы будут распакованы на клиенте, после чего пофайлово доставлены в память сессии через буфер.</p>
|
||||
</header>
|
||||
<!-- Скрытые input дают браузеру FileList; кнопки ниже программно вызывают click(). -->
|
||||
|
||||
<section class="toolbar" aria-label="Выбор файлов">
|
||||
<div id="file-picker"></div>
|
||||
</section>
|
||||
|
||||
<!-- Секция действий Слоя 2: пофайловый транзит в RAM сессии бэкенда -->
|
||||
<section class="upload-section" style="margin-top: 24px; padding: 20px; background: #f2f6f1; border-radius: 6px; border: 1px solid #c8d6c7;">
|
||||
<div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
|
||||
<button id="upload-btn" type="button" disabled style="background: #1b4d3e;">
|
||||
⚡ Загрузить в сессию (Слой 2 транзит)
|
||||
</button>
|
||||
<button id="cancel-btn" type="button" class="quiet" style="display: none; color: #8c5148;">
|
||||
✕ Отмена
|
||||
</button>
|
||||
<span id="upload-status" style="font: 14px sans-serif; color: #2d4a36;"></span>
|
||||
</div>
|
||||
<div id="session-info" style="margin-top: 14px; font: 13px sans-serif; display: none;">
|
||||
<strong>Сессия в RAM:</strong> <code id="session-id"></code>
|
||||
<div id="session-files" style="margin-top: 6px; color: #46604e;"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="{{ url_for('file_picker_dist', filename='file-picker.iife.js', v=version) }}"></script>
|
||||
<script>
|
||||
FilePicker.initFilePicker({
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
const uploadStatus = document.getElementById('upload-status');
|
||||
const sessionInfo = document.getElementById('session-info');
|
||||
const sessionIdEl = document.getElementById('session-id');
|
||||
const sessionFilesEl = document.getElementById('session-files');
|
||||
|
||||
let abortCtrl = null;
|
||||
let selectedFiles = [];
|
||||
|
||||
const picker = FilePicker.initFilePicker({
|
||||
mount: '#file-picker',
|
||||
allowedExt: {{ config.allowedExt|tojson }},
|
||||
onChange: (files) => {
|
||||
selectedFiles = files;
|
||||
uploadBtn.disabled = files.length === 0;
|
||||
uploadStatus.textContent = files.length ? `Готово к отправке: ${files.length} файл(ов)` : '';
|
||||
}
|
||||
});
|
||||
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
const files = picker.getFiles();
|
||||
if (!files.length) return;
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
cancelBtn.style.display = 'inline-block';
|
||||
sessionInfo.style.display = 'none';
|
||||
abortCtrl = new AbortController();
|
||||
|
||||
const vmUploadUrl = new URL({{ config.vmUploadUrl|tojson }}, window.location.origin).href;
|
||||
|
||||
const res = await FilePicker.uploadViaVM(files, {
|
||||
vmUploadUrl: vmUploadUrl,
|
||||
backendUploadUrl: '/api/upload_refs',
|
||||
signal: abortCtrl.signal,
|
||||
onStatus: (msg) => { uploadStatus.textContent = msg; },
|
||||
onFileStatus: (k, statusText) => {
|
||||
const row = document.querySelectorAll('.tree-row.tree-file')[k];
|
||||
if (row) {
|
||||
const statusCell = row.querySelectorAll('td')[2];
|
||||
if (statusCell) statusCell.innerHTML = statusText;
|
||||
}
|
||||
},
|
||||
onFileComplete: (info) => {
|
||||
console.log('File delivered to RAM:', info);
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.style.display = 'none';
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
|
||||
if (res.ok) {
|
||||
uploadStatus.innerHTML = '<span style="color:#1f5a3b; font-weight: bold;">✓ Все ' + res.count + ' файл(ов) успешно доставлены в RAM бэкенда!</span>';
|
||||
sessionIdEl.textContent = res.session;
|
||||
sessionInfo.style.display = 'block';
|
||||
|
||||
try {
|
||||
const sResp = await fetch(`/api/session/${res.session}/files`);
|
||||
const sData = await sResp.json();
|
||||
if (sData.ok) {
|
||||
sessionFilesEl.innerHTML = 'Сохранено в сессии RAM: ' +
|
||||
sData.files.map(f => `<code>${f.name}</code> (${f.size} B)`).join(', ');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load session files', e);
|
||||
}
|
||||
} else if (res.aborted) {
|
||||
uploadStatus.innerHTML = '<span style="color:#8c5148;">Загрузка отменена пользователем</span>';
|
||||
} else {
|
||||
uploadStatus.innerHTML = '<span style="color:#b32f22; font-weight: bold;">✗ Ошибка: ' + res.error + '</span>';
|
||||
}
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
if (abortCtrl) abortCtrl.abort();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user