Extract uploaded archives into local engagement storage

This commit is contained in:
A R R R Associates
2026-09-03 12:04:44 +05:30
parent 10e66f76c9
commit 7dff5ba393
3 changed files with 225 additions and 5 deletions
@@ -1,2 +1,2 @@
__version__ = "1.22.0" __version__ = "1.22.1"
AGENT_NAME = "ERP Local Agent" AGENT_NAME = "ERP Local Agent"
@@ -1,7 +1,11 @@
from __future__ import annotations from __future__ import annotations
import shutil import shutil
import stat
import tarfile
import time import time
import uuid
import zipfile
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -14,6 +18,166 @@ from .storage_identity import ensure_storage_identity
from . import __version__ from . import __version__
MAX_ARCHIVE_MEMBERS = 50000
MAX_ARCHIVE_EXTRACTED_BYTES = 20 * 1024 * 1024 * 1024 # 20 GiB hard safety ceiling.
def _archive_kind(path: Path) -> str | None:
"""Return a supported archive kind from both filename and file signature."""
name = path.name.lower()
wants_zip = name.endswith(".zip")
wants_tar = name.endswith((".tar", ".tar.gz", ".tgz"))
if not (wants_zip or wants_tar):
return None
if wants_zip:
if not zipfile.is_zipfile(path):
raise RuntimeError(f"Uploaded ZIP archive is invalid or damaged: {path.name}")
return "zip"
try:
if tarfile.is_tarfile(path):
return "tar"
except OSError:
pass
raise RuntimeError(f"Uploaded TAR archive is invalid or damaged: {path.name}")
def _archive_folder_name(filename: str) -> str:
lower = filename.lower()
if lower.endswith(".tar.gz"):
base = filename[:-7]
elif lower.endswith(".tgz"):
base = filename[:-4]
elif lower.endswith(".tar") or lower.endswith(".zip"):
base = filename.rsplit(".", 1)[0]
else:
base = Path(filename).stem
return sanitize_segment(base, "extracted")
def _safe_archive_member_relative(name: str) -> Path | None:
"""Convert an archive member name into a safe relative local path.
Absolute paths, drive-qualified paths and traversal components are rejected.
Individual path segments are normalised with the Local Agent's existing
filename sanitizer so extraction remains safe on Windows storage nodes.
"""
raw = str(name or "").replace("\\", "/").strip()
if not raw:
return None
if raw.startswith("/") or raw.startswith("//"):
raise RuntimeError(f"Unsafe absolute archive member path: {name}")
parts = []
for part in raw.split("/"):
if part in ("", "."):
continue
if part == "..":
raise RuntimeError(f"Unsafe archive traversal path: {name}")
if not parts and len(part) >= 2 and part[1] == ":":
raise RuntimeError(f"Unsafe drive-qualified archive member path: {name}")
parts.append(sanitize_segment(part, "item"))
return Path(*parts) if parts else None
def _ensure_extract_capacity(member_count: int, total_bytes: int) -> None:
if member_count > MAX_ARCHIVE_MEMBERS:
raise RuntimeError(f"Archive contains too many entries ({member_count:,}); maximum is {MAX_ARCHIVE_MEMBERS:,}.")
if total_bytes > MAX_ARCHIVE_EXTRACTED_BYTES:
raise RuntimeError(
f"Archive expands beyond the {MAX_ARCHIVE_EXTRACTED_BYTES // (1024 ** 3)} GiB local extraction safety limit."
)
def _extract_zip_safely(archive: Path, target_root: Path) -> None:
with zipfile.ZipFile(archive, "r") as zf:
infos = zf.infolist()
_ensure_extract_capacity(len(infos), sum(max(0, int(info.file_size or 0)) for info in infos))
for info in infos:
mode = (int(info.external_attr or 0) >> 16) & 0xFFFF
if mode and stat.S_ISLNK(mode):
raise RuntimeError(f"Archive contains an unsupported symbolic link: {info.filename}")
rel = _safe_archive_member_relative(info.filename)
if rel is None:
continue
destination = (target_root / rel).resolve()
if target_root.resolve() not in destination.parents and destination != target_root.resolve():
raise RuntimeError(f"Archive member resolves outside extraction folder: {info.filename}")
if info.is_dir():
destination.mkdir(parents=True, exist_ok=True)
continue
destination.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info, "r") as src, destination.open("wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
def _extract_tar_safely(archive: Path, target_root: Path) -> None:
with tarfile.open(archive, "r:*") as tf:
members = tf.getmembers()
_ensure_extract_capacity(len(members), sum(max(0, int(member.size or 0)) for member in members if member.isfile()))
for member in members:
if member.issym() or member.islnk() or member.isdev() or member.isfifo():
raise RuntimeError(f"Archive contains an unsupported link/device member: {member.name}")
rel = _safe_archive_member_relative(member.name)
if rel is None:
continue
destination = (target_root / rel).resolve()
if target_root.resolve() not in destination.parents and destination != target_root.resolve():
raise RuntimeError(f"Archive member resolves outside extraction folder: {member.name}")
if member.isdir():
destination.mkdir(parents=True, exist_ok=True)
continue
if not member.isfile():
continue
source = tf.extractfile(member)
if source is None:
raise RuntimeError(f"Could not read archive member: {member.name}")
destination.parent.mkdir(parents=True, exist_ok=True)
with source, destination.open("wb") as dst:
shutil.copyfileobj(source, dst, length=1024 * 1024)
def _preferred_extracted_path(extracted_root: Path) -> Path:
"""Use a single wrapper directory directly; otherwise return extraction root."""
try:
children = list(extracted_root.iterdir())
except OSError:
return extracted_root
if len(children) == 1 and children[0].is_dir():
return children[0]
return extracted_root
def _extract_archive_to_local_storage(archive: Path) -> Path | None:
"""Safely extract supported archives next to the stored source archive.
The original archive is intentionally retained. Existing secure download and
version/hash behaviour therefore remains unchanged, while users can work from
the extracted directory in branch-local storage.
"""
kind = _archive_kind(archive)
if kind is None:
return None
final_root = archive.parent / _archive_folder_name(archive.name)
temp_root = archive.parent / f".{final_root.name}.extracting-{uuid.uuid4().hex[:10]}"
if temp_root.exists():
shutil.rmtree(temp_root, ignore_errors=True)
temp_root.mkdir(parents=True, exist_ok=False)
try:
if kind == "zip":
_extract_zip_safely(archive, temp_root)
else:
_extract_tar_safely(archive, temp_root)
if final_root.exists():
if final_root.is_dir():
shutil.rmtree(final_root)
else:
final_root.unlink()
temp_root.replace(final_root)
except Exception:
shutil.rmtree(temp_root, ignore_errors=True)
raise
return _preferred_extracted_path(final_root)
class StorageAgent: class StorageAgent:
def __init__(self, config: AgentConfig, client: ERPClient, db: LocalDB, logger): def __init__(self, config: AgentConfig, client: ERPClient, db: LocalDB, logger):
self.config = config self.config = config
@@ -94,17 +258,49 @@ class StorageAgent:
response = self.client.download_storage_job(job_id) response = self.client.download_storage_job(job_id)
file_size, digest = write_stream_to_file(response.raw, destination) file_size, digest = write_stream_to_file(response.raw, destination)
final_relative_path = relative_path
try:
extracted_path = _extract_archive_to_local_storage(destination)
if extracted_path is not None:
final_relative_path = extracted_path.resolve().relative_to(self.config.storage_root.resolve())
self.logger.info(
"Archive storage job=%s extracted safely to %s",
job_id,
extracted_path,
)
except Exception as exc:
error_payload = {
"status": "FAILED",
"success": False,
"sha256_hash": digest,
"file_hash": digest,
"file_size": file_size,
"local_relative_path": str(relative_path).replace("\\", "/"),
"error": f"Archive extraction failed: {type(exc).__name__}: {exc}",
}
try:
self.client.acknowledge_storage_job(job_id, error_payload)
finally:
self.db.record_event("ERROR", "archive_extraction_failed", error_payload["error"])
raise
final_relative_text = str(final_relative_path).replace("\\", "/")
payload = { payload = {
"status": "COMPLETED", "status": "COMPLETED",
"success": True,
"sha256_hash": digest, "sha256_hash": digest,
"file_hash": digest, "file_hash": digest,
"file_size": file_size, "file_size": file_size,
"local_relative_path": str(relative_path).replace("\\", "/"), "local_relative_path": final_relative_text,
"local_final_path": final_relative_text,
"stored_at_utc": datetime.now(timezone.utc).isoformat(), "stored_at_utc": datetime.now(timezone.utc).isoformat(),
} }
self.client.acknowledge_storage_job(job_id, payload) self.client.acknowledge_storage_job(job_id, payload)
self.db.record_storage_job(str(job_id), str(relative_path).replace("\\", "/"), digest, file_size) self.db.record_storage_job(str(job_id), final_relative_text, digest, file_size)
self.logger.info("Storage job completed job=%s size=%s sha256=%s", job_id, file_size, digest) self.logger.info(
"Storage job completed job=%s size=%s sha256=%s final_path=%s",
job_id, file_size, digest, final_relative_text,
)
def process_download_requests(self) -> None: def process_download_requests(self) -> None:
try: try:
+25 -1
View File
@@ -6,6 +6,7 @@ from typing import Any, Iterable
from sqlalchemy import select from sqlalchemy import select
from app.modules.documents.models import DocumentStorageJob
from app.modules.documents.services import client_folder_parts, get_active_storage_node_for_branch, sanitize_segment from app.modules.documents.services import client_folder_parts, get_active_storage_node_for_branch, sanitize_segment
from app.modules.services.models import FirmServiceTaskTemplate, ServiceTaskCategory from app.modules.services.models import FirmServiceTaskTemplate, ServiceTaskCategory
@@ -41,6 +42,25 @@ def _join_local_path(root: str, relative: str) -> str:
return root + "/" + relative.lstrip("/") return root + "/" + relative.lstrip("/")
def _latest_local_final_path(db, version: Any) -> str:
"""Return the branch-agent acknowledged final path for a document version.
For ordinary files this is the file path. For ZIP/TAR uploads handled by Local
Agent 1.22.1+ this is the extracted directory path, while the original archive
continues to remain the versioned source file for secure download/history.
"""
version_id = int(getattr(version, "id", 0) or 0)
if not version_id:
return ""
job = db.execute(
select(DocumentStorageJob).where(
DocumentStorageJob.version_id == version_id,
DocumentStorageJob.status == "completed",
).order_by(DocumentStorageJob.completed_at_utc.desc(), DocumentStorageJob.id.desc()).limit(1)
).scalar_one_or_none()
return str(getattr(job, "local_final_path", "") or "").strip() if job else ""
def _task_category_options(db, engagement, tasks: list[Any]) -> list[dict[str, Any]]: def _task_category_options(db, engagement, tasks: list[Any]) -> list[dict[str, Any]]:
"""Return the service's real task-category master mapped to this engagement's tasks. """Return the service's real task-category master mapped to this engagement's tasks.
@@ -203,6 +223,8 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu
versions = list(getattr(doc, "versions", None) or []) versions = list(getattr(doc, "versions", None) or [])
latest = versions[0] if versions else None latest = versions[0] if versions else None
latest_relative_path = str(getattr(latest, "local_relative_path", "") or "") if latest else "" latest_relative_path = str(getattr(latest, "local_relative_path", "") or "") if latest else ""
local_final_path = _latest_local_final_path(db, latest) if latest else ""
displayed_relative_path = local_final_path or latest_relative_path
tally_year = _tally_document_year(doc) tally_year = _tally_document_year(doc)
row = { row = {
"id": int(getattr(doc, "id", 0) or 0), "id": int(getattr(doc, "id", 0) or 0),
@@ -213,6 +235,8 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu
"task_instance_id": getattr(doc, "task_instance_id", None), "task_instance_id": getattr(doc, "task_instance_id", None),
"latest_filename": str(getattr(latest, "original_filename", "") or "") if latest else "", "latest_filename": str(getattr(latest, "original_filename", "") or "") if latest else "",
"latest_relative_path": latest_relative_path, "latest_relative_path": latest_relative_path,
"local_final_path": local_final_path,
"displayed_relative_path": displayed_relative_path,
"storage_status": str(getattr(latest, "storage_status", "") or "") if latest else "", "storage_status": str(getattr(latest, "storage_status", "") or "") if latest else "",
} }
document_options.append(row) document_options.append(row)
@@ -220,7 +244,7 @@ def build_engagement_resource_context(db, engagement, tasks: Iterable[Any], docu
tally_data_files.append({ tally_data_files.append({
**row, **row,
"financial_year": tally_year, "financial_year": tally_year,
"local_path": _join_local_path(storage_root_path, latest_relative_path) if latest_relative_path else "", "local_path": _join_local_path(storage_root_path, displayed_relative_path) if displayed_relative_path else "",
}) })
tally_data_files.sort(key=lambda row: row.get("financial_year", ""), reverse=True) tally_data_files.sort(key=lambda row: row.get("financial_year", ""), reverse=True)