Volumes
This commit is contained in:
Binary file not shown.
@@ -11,14 +11,34 @@ class ResolvedPath:
|
||||
alias: str
|
||||
relative: str
|
||||
absolute: Path
|
||||
display_style: str
|
||||
|
||||
|
||||
class PathGuard:
|
||||
def __init__(self, root_aliases: dict[str, str]):
|
||||
normalized: dict[str, Path] = {}
|
||||
volume_roots_candidates: dict[str, list[tuple[str, Path]]] = {}
|
||||
for alias, root in root_aliases.items():
|
||||
normalized[alias] = Path(root).resolve()
|
||||
resolved_root = Path(root).resolve()
|
||||
normalized[alias] = resolved_root
|
||||
volume_name = resolved_root.name
|
||||
volume_roots_candidates.setdefault(volume_name, []).append((alias, resolved_root))
|
||||
self._roots = normalized
|
||||
self._volume_roots = {
|
||||
name: entries[0]
|
||||
for name, entries in volume_roots_candidates.items()
|
||||
if len(entries) == 1
|
||||
}
|
||||
|
||||
def is_virtual_volumes_path(self, input_path: str) -> bool:
|
||||
normalized_input = (input_path or "").strip()
|
||||
return normalized_input == "/Volumes"
|
||||
|
||||
def virtual_volumes_entries(self) -> list[dict[str, str]]:
|
||||
return [
|
||||
{"name": name, "path": f"/Volumes/{name}"}
|
||||
for name in sorted(self._volume_roots.keys(), key=str.lower)
|
||||
]
|
||||
|
||||
def resolve_directory_path(self, input_path: str) -> ResolvedPath:
|
||||
resolved = self.resolve_path(input_path)
|
||||
@@ -50,7 +70,7 @@ class PathGuard:
|
||||
return resolved
|
||||
|
||||
def resolve_path(self, input_path: str) -> ResolvedPath:
|
||||
alias, rel_segments, candidate = self.resolve_lexical_path(input_path)
|
||||
alias, rel_segments, candidate, display_style = self.resolve_lexical_path(input_path)
|
||||
root = self._roots[alias]
|
||||
|
||||
# Resolve symlinks for existing prefixes; for not-yet-existing tails strict=False keeps
|
||||
@@ -66,12 +86,14 @@ class PathGuard:
|
||||
|
||||
return ResolvedPath(
|
||||
alias=alias,
|
||||
relative=self._format_relative(alias, rel_segments),
|
||||
relative=self._format_relative(alias, rel_segments, display_style),
|
||||
absolute=resolved_candidate,
|
||||
display_style=display_style,
|
||||
)
|
||||
|
||||
def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path]:
|
||||
normalized_input = (input_path or "").strip().strip("/")
|
||||
def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path, str]:
|
||||
raw_input = (input_path or "").strip()
|
||||
normalized_input = raw_input.strip("/")
|
||||
if not normalized_input:
|
||||
raise AppError(
|
||||
code="invalid_request",
|
||||
@@ -80,16 +102,28 @@ class PathGuard:
|
||||
)
|
||||
|
||||
segments = [seg for seg in normalized_input.split("/") if seg]
|
||||
alias = segments[0] if segments else ""
|
||||
if alias not in self._roots:
|
||||
raise AppError(
|
||||
code="invalid_root_alias",
|
||||
message="Unknown root alias",
|
||||
status_code=403,
|
||||
details={"path": input_path},
|
||||
)
|
||||
display_style = "alias"
|
||||
alias = ""
|
||||
rel_segments: list[str] = []
|
||||
root: Path
|
||||
|
||||
if len(segments) >= 2 and segments[0] == "Volumes" and segments[1] in self._volume_roots:
|
||||
display_style = "virtual_volumes"
|
||||
volume_name = segments[1]
|
||||
alias, root = self._volume_roots[volume_name]
|
||||
rel_segments = segments[2:]
|
||||
else:
|
||||
alias = segments[0] if segments else ""
|
||||
if alias not in self._roots:
|
||||
raise AppError(
|
||||
code="invalid_root_alias",
|
||||
message="Unknown root alias",
|
||||
status_code=403,
|
||||
details={"path": input_path},
|
||||
)
|
||||
root = self._roots[alias]
|
||||
rel_segments = segments[1:]
|
||||
|
||||
rel_segments = segments[1:]
|
||||
if any(seg == ".." for seg in rel_segments):
|
||||
raise AppError(
|
||||
code="path_traversal_detected",
|
||||
@@ -98,9 +132,8 @@ class PathGuard:
|
||||
details={"path": input_path},
|
||||
)
|
||||
|
||||
root = self._roots[alias]
|
||||
candidate = root.joinpath(*rel_segments)
|
||||
return alias, rel_segments, candidate
|
||||
return alias, rel_segments, candidate, display_style
|
||||
|
||||
def validate_name(self, name: str, field: str) -> str:
|
||||
normalized = (name or "").strip()
|
||||
@@ -113,7 +146,7 @@ class PathGuard:
|
||||
)
|
||||
return normalized
|
||||
|
||||
def entry_relative_path(self, alias: str, absolute: Path) -> str:
|
||||
def entry_relative_path(self, alias: str, absolute: Path, display_style: str = "alias") -> str:
|
||||
root = self._roots[alias]
|
||||
resolved_absolute = absolute.resolve(strict=False)
|
||||
if not self._is_under_root(resolved_absolute, root):
|
||||
@@ -124,7 +157,7 @@ class PathGuard:
|
||||
details={"path": f"{alias}"},
|
||||
)
|
||||
rel = resolved_absolute.relative_to(root).as_posix()
|
||||
return self._format_relative(alias, [p for p in rel.split("/") if p])
|
||||
return self._format_relative(alias, [p for p in rel.split("/") if p], display_style)
|
||||
|
||||
@staticmethod
|
||||
def _is_under_root(path: Path, root: Path) -> bool:
|
||||
@@ -134,6 +167,9 @@ class PathGuard:
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _format_relative(alias: str, rel_segments: list[str]) -> str:
|
||||
def _format_relative(self, alias: str, rel_segments: list[str], display_style: str = "alias") -> str:
|
||||
if display_style == "virtual_volumes":
|
||||
root = self._roots[alias]
|
||||
prefix = f"/Volumes/{root.name}"
|
||||
return prefix if not rel_segments else f"{prefix}/{'/'.join(rel_segments)}"
|
||||
return alias if not rel_segments else f"{alias}/{'/'.join(rel_segments)}"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,13 +11,22 @@ class BrowseService:
|
||||
self._filesystem = filesystem
|
||||
|
||||
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
|
||||
if self._path_guard.is_virtual_volumes_path(path):
|
||||
directories = [
|
||||
DirectoryEntry(name=item["name"], path=item["path"], modified="")
|
||||
for item in self._path_guard.virtual_volumes_entries()
|
||||
]
|
||||
return BrowseResponse(path="/Volumes", directories=directories, files=[])
|
||||
|
||||
resolved = self._path_guard.resolve_directory_path(path)
|
||||
directories_raw, files_raw = self._filesystem.list_directory(resolved.absolute, show_hidden=show_hidden)
|
||||
|
||||
directories = [
|
||||
DirectoryEntry(
|
||||
name=item["name"],
|
||||
path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]),
|
||||
path=self._path_guard.entry_relative_path(
|
||||
resolved.alias, item["absolute"], display_style=resolved.display_style
|
||||
),
|
||||
modified=item["modified"],
|
||||
)
|
||||
for item in directories_raw
|
||||
@@ -26,7 +35,9 @@ class BrowseService:
|
||||
files = [
|
||||
FileEntry(
|
||||
name=item["name"],
|
||||
path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]),
|
||||
path=self._path_guard.entry_relative_path(
|
||||
resolved.alias, item["absolute"], display_style=resolved.display_style
|
||||
),
|
||||
size=item["size"],
|
||||
modified=item["modified"],
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ class CopyTaskService:
|
||||
|
||||
def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse:
|
||||
resolved_source = self._path_guard.resolve_existing_path(source)
|
||||
_, _, lexical_source = self._path_guard.resolve_lexical_path(source)
|
||||
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source)
|
||||
if lexical_source.is_symlink():
|
||||
raise AppError(
|
||||
code="type_conflict",
|
||||
@@ -36,7 +36,11 @@ class CopyTaskService:
|
||||
resolved_destination = self._path_guard.resolve_path(destination)
|
||||
|
||||
destination_parent = resolved_destination.absolute.parent
|
||||
parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent)
|
||||
parent_relative = self._path_guard.entry_relative_path(
|
||||
resolved_destination.alias,
|
||||
destination_parent,
|
||||
display_style=resolved_destination.display_style,
|
||||
)
|
||||
self._map_directory_validation(parent_relative)
|
||||
|
||||
if resolved_destination.absolute.exists():
|
||||
|
||||
@@ -68,7 +68,11 @@ class FileOpsService:
|
||||
resolved_source = self._path_guard.resolve_existing_path(path)
|
||||
safe_name = self._path_guard.validate_name(new_name, field="new_name")
|
||||
|
||||
parent_relative = self._path_guard.entry_relative_path(resolved_source.alias, resolved_source.absolute.parent)
|
||||
parent_relative = self._path_guard.entry_relative_path(
|
||||
resolved_source.alias,
|
||||
resolved_source.absolute.parent,
|
||||
display_style=resolved_source.display_style,
|
||||
)
|
||||
target_relative = self._join_relative(parent_relative, safe_name)
|
||||
resolved_target = self._path_guard.resolve_path(target_relative)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class MoveTaskService:
|
||||
|
||||
def create_move_task(self, source: str, destination: str) -> TaskCreateResponse:
|
||||
resolved_source = self._path_guard.resolve_existing_path(source)
|
||||
_, _, lexical_source = self._path_guard.resolve_lexical_path(source)
|
||||
_, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source)
|
||||
|
||||
if lexical_source.is_symlink():
|
||||
raise AppError(
|
||||
@@ -34,7 +34,11 @@ class MoveTaskService:
|
||||
|
||||
resolved_destination = self._path_guard.resolve_path(destination)
|
||||
destination_parent = resolved_destination.absolute.parent
|
||||
parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent)
|
||||
parent_relative = self._path_guard.entry_relative_path(
|
||||
resolved_destination.alias,
|
||||
destination_parent,
|
||||
display_style=resolved_destination.display_style,
|
||||
)
|
||||
self._map_directory_validation(parent_relative)
|
||||
|
||||
if resolved_destination.absolute.exists():
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -21,13 +21,21 @@ from backend.app.services.browse_service import BrowseService
|
||||
class BrowseApiGoldenTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name) / "root"
|
||||
self.volumes_root = Path(self.temp_dir.name) / "Volumes"
|
||||
self.volumes_root.mkdir(parents=True, exist_ok=True)
|
||||
self.root = self.volumes_root / "8TB"
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self.second_root = self.volumes_root / "8TB_RAID1"
|
||||
self.second_root.mkdir(parents=True, exist_ok=True)
|
||||
self.unconfigured_root = self.volumes_root / "Other"
|
||||
self.unconfigured_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
folder = self.root / "folder"
|
||||
folder.mkdir()
|
||||
file_path = self.root / "video.mkv"
|
||||
file_path.write_bytes(b"abc")
|
||||
second_file = self.second_root / "archive.txt"
|
||||
second_file.write_text("z", encoding="utf-8")
|
||||
|
||||
hidden_dir = self.root / ".hidden_dir"
|
||||
hidden_dir.mkdir()
|
||||
@@ -35,14 +43,14 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
||||
hidden_file.write_bytes(b"x")
|
||||
|
||||
mtime = 1710000000
|
||||
for path in [folder, file_path, hidden_dir, hidden_file]:
|
||||
for path in [folder, file_path, hidden_dir, hidden_file, second_file]:
|
||||
Path(path).touch()
|
||||
Path(path).chmod(0o755)
|
||||
import os
|
||||
os.utime(path, (mtime, mtime))
|
||||
|
||||
service = BrowseService(
|
||||
path_guard=PathGuard({"storage1": str(self.root)}),
|
||||
path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}),
|
||||
filesystem=FilesystemAdapter(),
|
||||
)
|
||||
async def _override_browse_service() -> BrowseService:
|
||||
@@ -100,6 +108,49 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
||||
self.assertEqual(directory_names, [".hidden_dir", "folder"])
|
||||
self.assertEqual(file_names, [".secret", "video.mkv"])
|
||||
|
||||
def test_browse_virtual_volumes_lists_only_configured_mounts(self) -> None:
|
||||
response = self._get("/Volumes")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"path": "/Volumes",
|
||||
"directories": [
|
||||
{"name": "8TB", "path": "/Volumes/8TB", "modified": ""},
|
||||
{"name": "8TB_RAID1", "path": "/Volumes/8TB_RAID1", "modified": ""},
|
||||
],
|
||||
"files": [],
|
||||
},
|
||||
)
|
||||
|
||||
def test_browse_virtual_mount_maps_to_configured_root(self) -> None:
|
||||
response = self._get("/Volumes/8TB")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
modified = datetime.fromtimestamp(1710000000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"path": "/Volumes/8TB",
|
||||
"directories": [
|
||||
{
|
||||
"name": "folder",
|
||||
"path": "/Volumes/8TB/folder",
|
||||
"modified": modified,
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"name": "video.mkv",
|
||||
"path": "/Volumes/8TB/video.mkv",
|
||||
"size": 3,
|
||||
"modified": modified,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -41,13 +41,16 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn("F6", body)
|
||||
self.assertIn("F7", body)
|
||||
self.assertIn("F8", body)
|
||||
self.assertIn("Alt+R", body)
|
||||
self.assertIn('id="viewer-modal"', body)
|
||||
self.assertIn('id="viewer-content"', body)
|
||||
self.assertIn('id="editor-modal"', body)
|
||||
self.assertIn('id="editor-content"', body)
|
||||
self.assertIn('id="editor-save-btn"', body)
|
||||
self.assertIn('id="editor-cancel-btn"', body)
|
||||
self.assertIn('id="rename-move-popup"', body)
|
||||
self.assertIn('id="rename-move-input"', body)
|
||||
self.assertIn('id="batch-move-popup"', body)
|
||||
self.assertIn('id="batch-move-apply-btn"', body)
|
||||
self.assertIn('id="mkdir-btn"', body)
|
||||
self.assertIn('id="copy-btn"', body)
|
||||
self.assertIn('id="move-btn"', body)
|
||||
@@ -77,6 +80,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
static_root = Path(mount.app.directory)
|
||||
self.assertTrue((static_root / "app.js").exists())
|
||||
self.assertTrue((static_root / "style.css").exists())
|
||||
app_js = (static_root / "app.js").read_text(encoding="utf-8")
|
||||
self.assertIn('currentPath: "/Volumes"', app_js)
|
||||
|
||||
app_js_url = app.url_path_for("ui", path="/app.js")
|
||||
style_css_url = app.url_path_for("ui", path="/style.css")
|
||||
|
||||
Binary file not shown.
@@ -11,6 +11,25 @@ from backend.app.config import get_settings
|
||||
|
||||
|
||||
class ConfigTest(unittest.TestCase):
|
||||
def test_default_root_aliases_include_storage1_and_storage2(self) -> None:
|
||||
original = os.environ.get("WEBMANAGER_ROOT_ALIASES")
|
||||
try:
|
||||
os.environ.pop("WEBMANAGER_ROOT_ALIASES", None)
|
||||
settings = get_settings()
|
||||
finally:
|
||||
if original is None:
|
||||
os.environ.pop("WEBMANAGER_ROOT_ALIASES", None)
|
||||
else:
|
||||
os.environ["WEBMANAGER_ROOT_ALIASES"] = original
|
||||
|
||||
self.assertEqual(
|
||||
settings.root_aliases,
|
||||
{
|
||||
"storage1": "/Volumes/8TB",
|
||||
"storage2": "/Volumes/8TB_RAID1",
|
||||
},
|
||||
)
|
||||
|
||||
def test_default_task_db_path_is_backend_data_absolute(self) -> None:
|
||||
original = os.environ.get("WEBMANAGER_TASK_DB_PATH")
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user