173 lines
5.6 KiB
Python
173 lines
5.6 KiB
Python
"""Сохранение логов, присланных полевыми клиентами на центральный сервер."""
|
|
|
|
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/<safe_client>/<timestamp>_<log_basename>.
|
|
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
|