diff --git a/backend/src/backend/resources.py b/backend/src/backend/resources.py index 6048ba5..c26bd76 100644 --- a/backend/src/backend/resources.py +++ b/backend/src/backend/resources.py @@ -55,15 +55,11 @@ def resource_payload( resource: DataResources, storage_object: StorageObjects, ) -> dict[str, Any]: - # ``object_key`` is stored as ``{ws_id}/{user_id}/.resources/{file_name}`` - # (see ``backend/services/storage.py:create_upload_record``). Two - # Jupyter-facing path views: - # - ``jupyter_accessible_path``: per-user Jupyter-relative path. The - # Jupyter root_dir is already ``workspace/{ws_id}/{user_id}/``, so - # we return the tail ``.resources/{file_name}`` for copy-paste use. - # - ``absolute_path``: full filesystem path inside the Jupyter - # container (the rclone mount root). Shape: - # ``{workspaces_root}/{ws_id}/{user_id}/.resources/{file_name}``. + # ``object_key`` now follows ``{ws_id}/{user_id}/{target_path}/{file_name}`` + # (target_path may be empty). Legacy objects still live under + # ``.resources/{file_name}`` and must remain readable. Both shapes share + # the same derivation: strip the workspace/user prefix and use the rest + # as the Jupyter-relative path. workspace_prefix = f"{resource.workspace_id}/" user_prefix = f"{resource.owner_user_id}/" jupyter_accessible_path = "" @@ -73,7 +69,7 @@ def resource_payload( ): remainder = storage_object.object_key[len(workspace_prefix):] if remainder.startswith(user_prefix): - tail = remainder[len(user_prefix):] # ``.resources/{file_name}`` + tail = remainder[len(user_prefix):] jupyter_accessible_path = tail absolute_path = ( workspaces_root() @@ -141,11 +137,7 @@ async def resource_jupyter_relative_path( raise HTTPException(422, "script_path must be a clean Jupyter-relative POSIX path") resource_path = resource_payload(resource, storage_object)["jupyter_accessible_path"] - if not resource_path.startswith(".resources/"): - raise HTTPException( - status.HTTP_409_CONFLICT, - "resource is not stored under .resources/", - ) + # Both legacy ``.resources/`` files and new flat/nested paths are valid. relative = compute_jupyter_relative_path(script_path, resource_path) return { "request_id": context.request_id, @@ -176,6 +168,7 @@ async def create_resource_upload( expected_size_bytes=payload.expected_size_bytes, expected_hash=payload.expected_hash, idempotency_key=idempotency_key, + target_path=payload.target_path, ), session, request, diff --git a/backend/src/backend/schemas.py b/backend/src/backend/schemas.py index 25a6af3..958588f 100644 --- a/backend/src/backend/schemas.py +++ b/backend/src/backend/schemas.py @@ -9,6 +9,18 @@ class CreateResourceUploadRequest(StrictModel): content_type: str = Field(min_length=1, max_length=255) expected_size_bytes: int = Field(ge=0, le=100 * 1024 * 1024) expected_hash: str | None = Field(default=None, min_length=64, max_length=64) + target_path: str = Field(default="", max_length=1024) + + @field_validator("target_path") + @classmethod + def validate_target_path(cls, value: str) -> str: + # POSIX 相对路径,不能含 ..、绝对前缀、控制字符 + if value and (value.startswith("/") or "\\" in value + or any(seg == ".." for seg in value.split("/")) + or any(ord(c) < 0x20 or ord(c) == 0x7F for c in value)): + raise ValueError("target_path must be a clean relative POSIX path") + # 去前导 /;允许尾部 / + return value.strip("/") @field_validator("expected_hash") @classmethod diff --git a/backend/src/backend/services/storage.py b/backend/src/backend/services/storage.py index 92eb85d..0c9d0fd 100644 --- a/backend/src/backend/services/storage.py +++ b/backend/src/backend/services/storage.py @@ -82,6 +82,14 @@ def _safe_file_name(value: str) -> str: return name +def _safe_path_segment(segment: str) -> str: + """Single path segment: keep alnum / dash / underscore / dot; collapse the rest.""" + cleaned = "".join( + c if c.isalnum() or c in "-_." else "_" for c in segment + ).strip("._") + return cleaned or "untitled" + + def _utcnow_naive() -> Any: from datetime import datetime, UTC return datetime.now(UTC).replace(tzinfo=None) @@ -92,6 +100,41 @@ def _hash_bytes(value: str) -> bytes: return _h.sha256(value.encode("utf-8")).digest() +async def _resolve_unique_object_key( + session: AsyncSession, + object_key: str, +) -> str: + """Append a ULID suffix when an available object already occupies ``object_key``. + + Keeps the original file name in metadata; only the on-disk key gets + the disambiguation suffix so repeated uploads of the same name never + overwrite existing bytes. + """ + key = object_key + for _ in range(5): + key_hash = _hash_bytes(key) + existing = await session.scalar( + select(StorageObjects).where( + StorageObjects.object_key_hash == key_hash, + StorageObjects.object_status == "available", + ) + ) + if existing is None: + return key + suffix = new_ulid().lower() + parts = key.rsplit("/", 1) + dir_part = parts[0] if len(parts) > 1 else "" + name = parts[-1] + # Preserve extension if present (ignore leading dot / hidden files). + if "." in name[1:]: + stem, ext = name.rsplit(".", 1) + new_name = f"{stem}-{suffix}.{ext}" + else: + new_name = f"{name}-{suffix}" + key = f"{dir_part}/{new_name}" if dir_part else new_name + return key + + def _build_storage_object( *, upload: UploadSessions, @@ -192,15 +235,25 @@ async def create_upload_record( payload.usage_type, workspace_artifact_bucket=workspace.artifact_bucket, ) - # Use the sanitized file_name as the on-disk basename (no ULID). The - # extension is still derived for StorageObjects.file_extension. safe_name = _safe_file_name(payload.file_name) - file_extension = PurePosixPath(safe_name).suffix.lower() - # Use the sanitized file_name as the on-disk basename (no ULID). - # DataResources files live under a per-user ``.resources`` subdir - # so they don't collide with workspace-tree scripts/directories - # the user authors directly in Jupyter. - object_key = f"{payload.workspace_id}/{payload.user_id}/.resources/{safe_name}" + # Defense-in-depth: schemas already validate target_path, but the + # helper is called directly from some callers so re-validate here. + target_path = (payload.target_path or "").strip("/") + if target_path and (target_path.startswith("/") or "\\" in target_path + or any(seg == ".." for seg in target_path.split("/")) + or any(ord(c) < 0x20 or ord(c) == 0x7F for c in target_path)): + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + "target_path must be a clean relative POSIX path", + ) + clean_target = "/".join( + _safe_path_segment(seg) for seg in target_path.split("/") if seg + ) + if clean_target: + object_key = f"{payload.workspace_id}/{payload.user_id}/{clean_target}/{safe_name}" + else: + object_key = f"{payload.workspace_id}/{payload.user_id}/{safe_name}" + object_key = await _resolve_unique_object_key(session, object_key) upload = UploadSessions( upload_id=new_ulid(), workspace_id=payload.workspace_id,