Files
multi-seal-mail/server/app/storage/transfers.py

448 lines
23 KiB
Python

from __future__ import annotations
from pathlib import PurePosixPath
from typing import Iterable
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.db.models import FileAsset, FileFolder
from app.storage.access import ensure_owner_access
from app.storage.common import FileConflictResolution, FileStorageError, RenamePlanItem, utcnow
from app.storage.files import (
_active_asset_exists,
_asset_owner_id,
_asset_query_for_owner,
_candidate_renamed_path,
_copy_asset_to_path,
_next_available_logical_path,
_normalize_conflict_strategy,
_resolution_by_path,
_soft_delete_conflicting_asset,
current_version_and_blob,
get_asset_for_user,
rename_asset,
)
from app.storage.folders import _active_folder_exists, _ensure_target_folder_hierarchy, _folder_query_for_owner
from app.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path
def transfer_selection(
session: Session,
*,
tenant_id: str,
user_id: str,
operation: str,
file_ids: list[str],
folder_paths: list[str],
source_owner_type: str,
source_owner_id: str,
target_owner_type: str,
target_owner_id: str,
target_folder: str,
conflict_strategy: str = "reject",
conflict_resolutions: Iterable[FileConflictResolution] | None = None,
is_admin: bool = False,
) -> tuple[int, int]:
"""Move or copy files/folders between user/group file spaces.
Folder transfers preserve the selected folder's basename below the target
folder. File transfers place files directly in the target folder. Existing
active target paths are handled by the requested conflict strategy. Copies
create new file assets/versions that reference the existing immutable blob.
"""
operation = operation.lower().strip()
if operation not in {"move", "copy"}:
raise FileStorageError("Unsupported transfer operation")
source_owner_type = source_owner_type.lower().strip()
target_owner_type = target_owner_type.lower().strip()
source_folder_paths = [normalize_folder(path) for path in folder_paths if normalize_folder(path)]
target_folder = normalize_folder(target_folder)
conflict_strategy = _normalize_conflict_strategy(conflict_strategy)
conflict_resolution_map = _resolution_by_path(conflict_resolutions)
ensure_owner_access(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id, user_id=user_id, is_admin=is_admin)
ensure_owner_access(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, is_admin=is_admin)
if operation == "move" and source_owner_type == target_owner_type and source_owner_id == target_owner_id:
for folder_path in source_folder_paths:
if target_folder == folder_path or target_folder.startswith(f"{folder_path}/"):
raise FileStorageError("Cannot move a folder into itself or one of its child folders")
assets_by_id: dict[str, FileAsset] = {}
for file_id in file_ids:
asset = get_asset_for_user(
session,
tenant_id=tenant_id,
user_id=user_id,
asset_id=file_id,
require_write=operation == "move",
is_admin=is_admin,
)
assets_by_id[asset.id] = asset
folder_asset_targets: dict[str, str] = {}
folder_target_paths: dict[str, str] = {}
for folder_path in source_folder_paths:
folder_basename = PurePosixPath(folder_path).name
target_prefix = normalize_folder(f"{target_folder}/{folder_basename}" if target_folder else folder_basename)
folder_target_paths[folder_path] = target_prefix
if operation == "copy" or not (source_owner_type == target_owner_type and source_owner_id == target_owner_id and target_prefix == folder_path):
if _active_folder_exists(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, path=target_prefix):
resolution = conflict_resolution_map.get(target_prefix)
action = resolution.action if resolution else conflict_strategy
if action == "skip":
folder_target_paths.pop(folder_path, None)
continue
if action == "rename":
# Rename the selected folder root while preserving its contents below it.
counter = 1
candidate = target_prefix
while _active_folder_exists(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, path=candidate):
candidate = _candidate_renamed_path(target_prefix, counter)
counter += 1
target_prefix = normalize_folder(candidate)
folder_target_paths[folder_path] = target_prefix
elif action == "reject":
raise FileStorageError(f"Target folder already exists: {target_prefix}")
# overwrite on folders means merge into the existing folder; conflicting files are still handled below.
source_assets = _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id).filter(
FileAsset.deleted_at.is_(None),
FileAsset.display_path.like(f"{folder_path}/%"),
).all()
for asset in source_assets:
relative = asset.display_path[len(folder_path) + 1 :]
folder_asset_targets[asset.id] = normalize_logical_path(f"{target_prefix}/{relative}")
assets_by_id[asset.id] = asset
direct_file_targets: dict[str, str] = {}
for file_id, asset in assets_by_id.items():
if file_id in folder_asset_targets:
continue
direct_file_targets[file_id] = normalize_logical_path(join_folder_filename(target_folder, filename_from_path(asset.display_path)))
all_targets = {**folder_asset_targets, **direct_file_targets}
if not all_targets and not source_folder_paths:
return 0, 0
resolved_targets: dict[str, str] = {}
skipped_asset_ids: set[str] = set()
reserved_targets: set[str] = set()
for asset_id, target_path in all_targets.items():
source_asset = assets_by_id[asset_id]
same_owner = (source_asset.owner_type == target_owner_type and _asset_owner_id(source_asset) == target_owner_id)
exclude = source_asset.id if operation == "move" and same_owner else None
normalized_target = normalize_logical_path(target_path)
resolution = conflict_resolution_map.get(normalized_target)
action = resolution.action if resolution else conflict_strategy
if resolution and resolution.new_path and action == "rename":
normalized_target = normalize_logical_path(resolution.new_path)
conflict = _active_asset_exists(
session,
tenant_id=tenant_id,
owner_type=target_owner_type,
owner_id=target_owner_id,
path=normalized_target,
exclude_asset_id=exclude,
) or normalized_target in reserved_targets
if conflict:
if action == "reject":
raise FileStorageError(f"Target file already exists: {normalized_target}")
if action == "skip":
skipped_asset_ids.add(asset_id)
continue
if action == "overwrite":
_soft_delete_conflicting_asset(
session,
tenant_id=tenant_id,
owner_type=target_owner_type,
owner_id=target_owner_id,
path=normalized_target,
exclude_asset_id=exclude,
)
elif action == "rename":
normalized_target = _next_available_logical_path(
session,
tenant_id=tenant_id,
owner_type=target_owner_type,
owner_id=target_owner_id,
desired_path=normalized_target,
reserved_paths=reserved_targets,
exclude_asset_id=exclude,
)
elif action == "rename":
normalized_target = _next_available_logical_path(
session,
tenant_id=tenant_id,
owner_type=target_owner_type,
owner_id=target_owner_id,
desired_path=normalized_target,
reserved_paths=reserved_targets,
exclude_asset_id=exclude,
)
reserved_targets.add(normalized_target)
resolved_targets[asset_id] = normalized_target
all_targets = resolved_targets
copied_or_moved_files = 0
copied_or_moved_folders = 0
# Create target folder hierarchy first, including selected folder roots and
# any nested folders/files parents.
same_owner_transfer = source_owner_type == target_owner_type and source_owner_id == target_owner_id
for target_path in folder_target_paths.values():
path_to_create = target_path
if operation == "move" and same_owner_transfer:
parent = str(PurePosixPath(target_path).parent)
path_to_create = "" if parent == "." else parent
if path_to_create:
_ensure_target_folder_hierarchy(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, path=path_to_create)
copied_or_moved_folders += 1
for target_path in all_targets.values():
parent = str(PurePosixPath(target_path).parent)
if parent and parent != ".":
_ensure_target_folder_hierarchy(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, path=parent)
if operation == "copy":
for asset_id, target_path in all_targets.items():
_copy_asset_to_path(session, assets_by_id[asset_id], tenant_id=tenant_id, target_owner_type=target_owner_type, target_owner_id=target_owner_id, target_path=target_path, user_id=user_id)
copied_or_moved_files += 1
return copied_or_moved_files, copied_or_moved_folders
now = utcnow()
for asset_id, target_path in all_targets.items():
asset = assets_by_id[asset_id]
same_owner = asset.owner_type == target_owner_type and _asset_owner_id(asset) == target_owner_id
if same_owner:
if normalize_logical_path(asset.display_path) == target_path:
continue
rename_asset(asset, new_path=target_path)
session.add(asset)
else:
_copy_asset_to_path(session, asset, tenant_id=tenant_id, target_owner_type=target_owner_type, target_owner_id=target_owner_id, target_path=target_path, user_id=user_id)
asset.deleted_at = now
session.add(asset)
copied_or_moved_files += 1
# Move/copy persisted folder records after files. For cross-owner moves, create
# target records and soft-delete source records. For same-owner moves, rename
# them in place.
for source_path, target_prefix in folder_target_paths.items():
folder_rows = _folder_query_for_owner(session, tenant_id=tenant_id, owner_type=source_owner_type, owner_id=source_owner_id).filter(
FileFolder.deleted_at.is_(None),
or_(FileFolder.path == source_path, FileFolder.path.like(f"{source_path}/%")),
).all()
for folder in folder_rows:
relative = "" if folder.path == source_path else folder.path[len(source_path) + 1 :]
new_path = normalize_folder(f"{target_prefix}/{relative}" if relative else target_prefix)
if operation == "move" and source_owner_type == target_owner_type and source_owner_id == target_owner_id:
folder.path = new_path
session.add(folder)
else:
_ensure_target_folder_hierarchy(session, tenant_id=tenant_id, owner_type=target_owner_type, owner_id=target_owner_id, user_id=user_id, path=new_path)
if operation == "move":
folder.deleted_at = now
session.add(folder)
return copied_or_moved_files, copied_or_moved_folders
def _rename_basename(name: str, *, mode: str, new_name: str | None = None, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str:
if mode == "direct":
cleaned = normalize_logical_path(new_name or "", fallback_filename="item")
if "/" in cleaned:
raise FileStorageError("Rename expects a name, not a path")
return cleaned
stem_path = PurePosixPath(name)
suffixes = "".join(stem_path.suffixes)
stem = name[: -len(suffixes)] if suffixes else name
if mode == "prefix":
return prefix + name
if mode == "suffix":
return f"{stem}{suffix}{suffixes}"
if mode == "replace":
return name.replace(find or "", replacement) if find else name
raise FileStorageError("Unsupported rename mode")
def _rename_path(path: str, *, mode: str, new_name: str | None = None, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str:
normalized = normalize_logical_path(path)
logical = PurePosixPath(normalized)
folder = "" if str(logical.parent) == "." else str(logical.parent)
next_name = _rename_basename(logical.name, mode=mode, new_name=new_name, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
return normalize_logical_path(f"{folder}/{next_name}" if folder else next_name)
def _rename_relative_recursive(relative_path: str, *, mode: str, new_name: str | None = None, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str:
parts = normalize_logical_path(relative_path).split("/")
return normalize_logical_path(
"/".join(
_rename_basename(part, mode=mode, new_name=new_name if len(parts) == 1 else None, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
for part in parts
if part
)
)
def build_rename_preview(asset: FileAsset, *, mode: str, find: str | None = None, replacement: str = "", prefix: str = "", suffix: str = "") -> str:
return _rename_path(asset.display_path, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
def _collapse_folder_roots(folder_paths: list[str]) -> list[str]:
roots = sorted({normalize_folder(path) for path in folder_paths if normalize_folder(path)}, key=lambda item: (item.count("/"), item))
collapsed: list[str] = []
for path in roots:
if any(path == root or path.startswith(f"{root}/") for root in collapsed):
continue
collapsed.append(path)
return collapsed
def _path_under_root(path: str, root: str) -> bool:
normalized = normalize_logical_path(path)
return normalized == root or normalized.startswith(f"{root}/")
def _folder_new_path_for_root(path: str, root: str, new_root: str, *, recursive: bool, mode: str, find: str | None, replacement: str, prefix: str, suffix: str) -> str:
if path == root:
return normalize_folder(new_root)
relative = path[len(root) + 1 :]
if recursive:
relative = _rename_relative_recursive(relative, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
return normalize_folder(f"{new_root}/{relative}")
def _file_new_path_for_root(path: str, root: str, new_root: str, *, recursive: bool, mode: str, find: str | None, replacement: str, prefix: str, suffix: str) -> str:
relative = path[len(root) + 1 :]
if recursive:
relative = _rename_relative_recursive(relative, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
return normalize_logical_path(f"{new_root}/{relative}")
def rename_selection(
session: Session,
*,
tenant_id: str,
user_id: str,
file_ids: list[str],
folder_paths: list[str],
owner_type: str | None,
owner_id: str | None,
mode: str,
new_name: str | None = None,
find: str | None = None,
replacement: str = "",
prefix: str = "",
suffix: str = "",
recursive: bool = False,
dry_run: bool = True,
is_admin: bool = False,
) -> list[RenamePlanItem]:
mode = mode.lower().strip()
if mode not in {"direct", "prefix", "suffix", "replace"}:
raise FileStorageError("Unsupported rename mode")
selected_file_ids = list(dict.fromkeys(file_ids))
selected_folder_roots = _collapse_folder_roots(folder_paths)
if not selected_file_ids and not selected_folder_roots:
raise FileStorageError("No files or folders selected")
if selected_folder_roots and (not owner_type or not owner_id):
raise FileStorageError("Folder rename requires an owner file space")
if mode == "direct" and (len(selected_file_ids) + len(selected_folder_roots)) != 1:
raise FileStorageError("Direct rename requires exactly one selected item")
assets: dict[str, FileAsset] = {}
for file_id in selected_file_ids:
asset = get_asset_for_user(session, tenant_id=tenant_id, user_id=user_id, asset_id=file_id, require_write=True, is_admin=is_admin)
assets[asset.id] = asset
owner_type_norm = owner_type.lower().strip() if owner_type else None
owner_id_norm = owner_id
folder_rows_by_path: dict[str, FileFolder] = {}
affected_folder_paths: set[str] = set()
if selected_folder_roots and owner_type_norm and owner_id_norm:
ensure_owner_access(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, user_id=user_id, is_admin=is_admin)
for root in selected_folder_roots:
rows = _folder_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm).filter(
FileFolder.deleted_at.is_(None),
or_(FileFolder.path == root, FileFolder.path.like(f"{root}/%")),
).all()
if not rows and not _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm).filter(FileAsset.deleted_at.is_(None), FileAsset.display_path.like(f"{root}/%")).first():
raise FileStorageError(f"Folder not found: {root}")
for row in rows:
folder_rows_by_path[row.path] = row
affected_folder_paths.add(row.path)
for asset in _asset_query_for_owner(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm).filter(FileAsset.deleted_at.is_(None), FileAsset.display_path.like(f"{root}/%")).all():
assets[asset.id] = asset
folder_plan: dict[str, str] = {}
root_target_paths: dict[str, str] = {}
for root in selected_folder_roots:
if mode == "direct":
root_parent = "" if str(PurePosixPath(root).parent) == "." else str(PurePosixPath(root).parent)
root_new_name = _rename_basename(PurePosixPath(root).name, mode=mode, new_name=new_name)
new_root = normalize_folder(f"{root_parent}/{root_new_name}" if root_parent else root_new_name)
else:
new_root = normalize_folder(_rename_path(root, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix))
root_target_paths[root] = new_root
if root in affected_folder_paths:
folder_plan[root] = new_root
for path in sorted(affected_folder_paths, key=lambda item: item.count("/")):
if path != root and _path_under_root(path, root):
folder_plan[path] = _folder_new_path_for_root(path, root, new_root, recursive=recursive, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
file_plan: dict[str, str] = {}
for asset in assets.values():
matched_root = next((root for root in sorted(selected_folder_roots, key=len, reverse=True) if _path_under_root(asset.display_path, root)), None)
if matched_root:
root_new = root_target_paths.get(matched_root)
if not root_new:
continue
file_plan[asset.id] = _file_new_path_for_root(asset.display_path, matched_root, root_new, recursive=recursive, mode=mode, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
else:
file_plan[asset.id] = _rename_path(asset.display_path, mode=mode, new_name=new_name, find=find, replacement=replacement, prefix=prefix, suffix=suffix)
# Detect duplicate targets and existing active target conflicts before applying.
target_files: set[str] = set()
target_folders: set[str] = set()
affected_file_ids = set(file_plan)
for asset_id, target in file_plan.items():
normalized = normalize_logical_path(target)
if normalized in target_files:
raise FileStorageError(f"Rename would create duplicate file path: {normalized}")
target_files.add(normalized)
asset = assets[asset_id]
if _active_asset_exists(session, tenant_id=tenant_id, owner_type=asset.owner_type, owner_id=_asset_owner_id(asset), path=normalized, exclude_asset_id=asset.id):
raise FileStorageError(f"Target file already exists: {normalized}")
if _active_folder_exists(session, tenant_id=tenant_id, owner_type=asset.owner_type, owner_id=_asset_owner_id(asset), path=normalized):
raise FileStorageError(f"Target path is already a folder: {normalized}")
if selected_folder_roots and owner_type_norm and owner_id_norm:
for old_path, target in folder_plan.items():
normalized = normalize_folder(target)
if normalized in target_folders:
raise FileStorageError(f"Rename would create duplicate folder path: {normalized}")
target_folders.add(normalized)
if _active_folder_exists(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, path=normalized, exclude_paths=set(folder_plan.keys())):
raise FileStorageError(f"Target folder already exists: {normalized}")
if _active_asset_exists(session, tenant_id=tenant_id, owner_type=owner_type_norm, owner_id=owner_id_norm, path=normalized):
raise FileStorageError(f"Target path is already a file: {normalized}")
plan: list[RenamePlanItem] = []
for old_path, new_path in sorted(folder_plan.items()):
plan.append(RenamePlanItem(kind="folder", id=old_path, old_path=old_path, new_path=new_path))
for asset_id, new_path in file_plan.items():
asset = assets[asset_id]
plan.append(RenamePlanItem(kind="file", id=asset.id, old_path=asset.display_path, new_path=new_path))
if dry_run:
return plan
for old_path, new_path in folder_plan.items():
folder = folder_rows_by_path.get(old_path)
if folder:
folder.path = normalize_folder(new_path)
session.add(folder)
for asset_id, new_path in file_plan.items():
rename_asset(assets[asset_id], new_path=new_path)
session.add(assets[asset_id])
return plan