"""Сохранение логов, присланных полевыми клиентами на центральный сервер.""" from __future__ import annotations import re import time from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional from sqlalchemy import or_, select _LOG_NAME_RE = re.compile(r"^[\w.\-]{1,120}$") def safe_path_segment(raw: str, max_len: int = 96) -> str: s = (raw or "").strip()[:max_len] if not s: return "unknown" return "".join(c if (c.isalnum() or c in "-_") else "_" for c in s) def validate_log_basename(name: str) -> str: n = (name or "").strip() or "wesp.log" if not _LOG_NAME_RE.match(n): raise ValueError("Недопустимое имя файла лога") return n def store_client_log( *, upload_dir: str, client_id: str, log_basename: str, raw: bytes, ) -> Dict[str, Any]: """ Пишет байты в upload_dir//_. upload_dir должен быть абсолютным или относительным к cwd процесса. """ if not upload_dir or not str(upload_dir).strip(): raise ValueError("Каталог выгрузки логов не задан") cid = safe_path_segment(client_id, max_len=80) base = validate_log_basename(log_basename) root = Path(upload_dir).resolve() dest_dir = root / cid dest_dir.mkdir(parents=True, exist_ok=True) ts = time.strftime("%Y%m%d_%H%M%S", time.gmtime()) dest = dest_dir / f"{ts}_{base}" dest.write_bytes(raw) try: rel = dest.relative_to(root) except ValueError: rel = dest return { "path": str(dest), "relative": str(rel).replace("\\", "/"), "bytes_written": len(raw), } def client_logs_root_resolved(upload_dir: str, base_dir: str) -> Path: raw = (upload_dir or "").strip() if not raw: raise ValueError("Каталог выгрузки логов не задан") p = Path(raw) if not p.is_absolute(): p = Path(base_dir) / p return p.resolve() def _is_inside(parent: Path, child: Path) -> bool: try: child.resolve().relative_to(parent.resolve()) return True except ValueError: return False def client_upload_log_dir(root: Path, client_id: str) -> Path: seg = safe_path_segment(client_id, max_len=80) d = (root / seg).resolve() if not _is_inside(root, d) and d != root: raise ValueError("Некорректный client_id") return d def list_uploaded_client_log_packages(root: Path) -> List[Dict[str, Any]]: if not root.is_dir(): return [] out: List[Dict[str, Any]] = [] for sub in sorted(root.iterdir(), key=lambda x: x.name.lower()): if not sub.is_dir(): continue files: List[Dict[str, Any]] = [] for f in sorted(sub.iterdir(), key=lambda x: x.name): if not f.is_file() or not _LOG_NAME_RE.match(f.name): continue st = f.stat() files.append( { "name": f.name, "size": st.st_size, "mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(), } ) out.append({"dir_segment": sub.name, "file_count": len(files), "files": files}) return out def build_uploaded_logs_index(db_session, root: Path) -> List[Dict[str, Any]]: """Список каталогов выгрузки + сопоставление с SyncClient по safe_path_segment(node_id).""" from app.models import SyncClient, SyncClientDisplayName packages = list_uploaded_client_log_packages(root) clients = list( db_session.execute( select(SyncClient).where( or_(SyncClient.is_deleted.is_(False), SyncClient.is_deleted.is_(None)) ) ).scalars().all() ) names = { n.node_id: n.display_name for n in db_session.execute(select(SyncClientDisplayName)).scalars().all() } by_seg = {safe_path_segment(c.node_id, max_len=80): c for c in clients} result: List[Dict[str, Any]] = [] for p in packages: seg = p["dir_segment"] sc = by_seg.get(seg) nid = sc.node_id if sc else None result.append( { "dir_segment": seg, "node_id": nid, "client_name": (sc.client_name if sc else None), "display_name": (names.get(nid) if nid else None), "file_count": p["file_count"], "files": p["files"], "api_client_id": nid or seg, } ) return result def latest_uploaded_log_file(client_dir: Path) -> Optional[Path]: if not client_dir.is_dir(): return None candidates = [ f for f in client_dir.iterdir() if f.is_file() and _LOG_NAME_RE.match(f.name) ] if not candidates: return None return max(candidates, key=lambda p: p.stat().st_mtime) def resolve_uploaded_client_log_file(root: Path, client_id: str, filename: str) -> Path: d = client_upload_log_dir(root, client_id) if not d.is_dir(): raise FileNotFoundError("Каталог клиента не найден") fn = Path(filename).name if not _LOG_NAME_RE.match(fn): raise ValueError("Недопустимое имя файла") fpath = (d / fn).resolve() if not _is_inside(d, fpath) and fpath != d: raise ValueError("Некорректный путь") if not fpath.is_file(): raise FileNotFoundError("Файл не найден") return fpath