mock server, file and folder management
This commit is contained in:
73
server/app/storage/paths.py
Normal file
73
server/app/storage/paths.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from uuid import uuid4
|
||||
|
||||
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.@ -]+")
|
||||
|
||||
|
||||
class UnsafeFilePathError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_logical_path(path: str | None, *, fallback_filename: str | None = None) -> str:
|
||||
"""Return a safe tenant-relative logical path using POSIX separators.
|
||||
|
||||
The logical path is metadata, not a filesystem path. It never starts with a
|
||||
slash and cannot contain path traversal components. It is used for browsing,
|
||||
wildcard matching and attachment rules.
|
||||
"""
|
||||
|
||||
raw = (path or "").replace("\\", "/").strip()
|
||||
if not raw and fallback_filename:
|
||||
raw = fallback_filename
|
||||
if not raw:
|
||||
raise UnsafeFilePathError("File path is empty")
|
||||
if raw.startswith("/"):
|
||||
raw = raw.lstrip("/")
|
||||
parts: list[str] = []
|
||||
for part in raw.split("/"):
|
||||
clean = part.strip()
|
||||
if not clean or clean == ".":
|
||||
continue
|
||||
if clean == "..":
|
||||
raise UnsafeFilePathError("Path traversal is not allowed")
|
||||
parts.append(clean)
|
||||
if not parts:
|
||||
raise UnsafeFilePathError("File path is empty")
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def normalize_folder(path: str | None) -> str:
|
||||
raw = (path or "").replace("\\", "/").strip().strip("/")
|
||||
if not raw:
|
||||
return ""
|
||||
normalized = normalize_logical_path(raw)
|
||||
return "" if normalized == "." else normalized
|
||||
|
||||
|
||||
def filename_from_path(path: str) -> str:
|
||||
name = PurePosixPath(path).name
|
||||
if not name or name in {".", ".."}:
|
||||
raise UnsafeFilePathError("Invalid filename")
|
||||
return name
|
||||
|
||||
|
||||
def join_folder_filename(folder: str | None, filename: str) -> str:
|
||||
safe_name = sanitize_filename(filename)
|
||||
safe_folder = normalize_folder(folder)
|
||||
return f"{safe_folder}/{safe_name}" if safe_folder else safe_name
|
||||
|
||||
|
||||
def sanitize_filename(filename: str | None) -> str:
|
||||
raw = (filename or "file").replace("\\", "/").split("/")[-1].strip()
|
||||
raw = raw.strip(".") or "file"
|
||||
safe = _SAFE_NAME_RE.sub("_", raw)
|
||||
safe = re.sub(r"\s+", " ", safe).strip()
|
||||
return safe or f"file-{uuid4().hex}"
|
||||
|
||||
|
||||
def safe_storage_component(value: str | None, fallback: str = "file") -> str:
|
||||
safe = sanitize_filename(value or fallback)
|
||||
return safe.replace(" ", "_")[:180]
|
||||
Reference in New Issue
Block a user