@@ -0,0 +1,172 @@
|
||||
"""Сохранение логов, присланных полевыми клиентами на центральный сервер."""
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user