update: rel path

This commit is contained in:
tao.chen
2026-08-13 19:51:20 +08:00
parent 63a14b1884
commit 0b5b50edb9
3 changed files with 121 additions and 16 deletions
+73 -9
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import base64
import os
from datetime import UTC, datetime
from pathlib import Path, PurePosixPath
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
@@ -10,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from common.db.models import DataResources, StorageObjects
from common.ids import new_ulid
from common.storage import workspaces_root
from common.storage.schemas import (
CreateUploadRequest,
DownloadUrlRequest,
@@ -25,6 +28,7 @@ from backend.schemas import (
CreateResourceUploadRequest,
DownloadUrlRequest,
)
from backend.schemas import ResourceRelativePathRequest
from backend.services.storage import (
create_download_url_payload,
create_server_object_payload,
@@ -36,23 +40,49 @@ from backend.services.storage import (
router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"])
def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str:
"""从当前脚本所在目录算到资源文件的 Jupyter 相对路径。
script_path / resource_relative 都是相对于 user root_dir 的 POSIX 路径。
"""
script_dir = PurePosixPath(script_path).parent.as_posix()
if not script_dir or script_dir == ".":
return resource_relative
return os.path.relpath(resource_relative, start=script_dir)
def resource_payload(
resource: DataResources,
storage_object: StorageObjects,
) -> dict[str, Any]:
# The Jupyter-side path: storage_object.object_key is stored as
# "workspace/{ws_id}/{user_id}/{ulid}{ext}" and Jupyter's root_dir is
# "/workspace/{ws_id}", so Jupyter sees the file at
# "{user_id}/{ulid}{ext}". Strip the workspace/{ws_id}/ prefix so the
# frontend can render a copy-pasteable Jupyter path.
# ``object_key`` is stored as ``workspace/{ws_id}/{user_id}/.resources/{file_name}``.
# 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
# only 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}``.
workspace_prefix = f"workspace/{resource.workspace_id}/"
user_prefix = f"{resource.owner_user_id}/"
jupyter_accessible_path = ""
absolute_path = ""
if storage_object.object_key and storage_object.object_key.startswith(
workspace_prefix
):
jupyter_accessible_path = storage_object.object_key[
len(workspace_prefix) :
]
else:
remainder = storage_object.object_key[len(workspace_prefix):]
if remainder.startswith(user_prefix):
tail = remainder[len(user_prefix):] # ``.resources/{file_name}``
jupyter_accessible_path = tail
absolute_path = (
workspaces_root()
/ resource.workspace_id
/ resource.owner_user_id
/ Path(tail)
).as_posix()
else:
jupyter_accessible_path = remainder
elif storage_object.object_key:
jupyter_accessible_path = storage_object.object_key
return {
"resource_id": resource.resource_id,
@@ -74,6 +104,7 @@ def resource_payload(
"object_status": storage_object.object_status,
},
"jupyter_accessible_path": jupyter_accessible_path,
"absolute_path": absolute_path,
}
@@ -89,6 +120,39 @@ def can_view(resource: DataResources, context: RequestContext) -> bool:
)
@router.post("/{resource_id}/jupyter-relative-path")
async def resource_jupyter_relative_path(
resource_id: str,
payload: ResourceRelativePathRequest,
context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
resource, storage_object = await get_visible_resource(
resource_id, context, session
)
script_path = payload.script_path.strip()
if (
script_path.startswith("/")
or "\\" in script_path
or any(seg == ".." for seg in script_path.split("/"))
or any(ord(c) < 0x20 or ord(c) == 0x7F for c in script_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/",
)
relative = compute_jupyter_relative_path(script_path, resource_path)
return {
"request_id": context.request_id,
"data": {"relative_path": relative},
"meta": {},
}
@router.post("/uploads", status_code=status.HTTP_201_CREATED)
async def create_resource_upload(
payload: CreateResourceUploadRequest,
+4
View File
@@ -60,3 +60,7 @@ class PublishVersionRequest(StrictModel):
class DownloadUrlRequest(StrictModel):
expires_seconds: int = Field(default=300, ge=30, le=3600)
class ResourceRelativePathRequest(StrictModel):
script_path: str = Field(min_length=1, max_length=512)
+44 -7
View File
@@ -38,6 +38,7 @@ from typing import Any
from fastapi import HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError
from common.config import settings
from common.db.models import StorageObjects, UploadSessions
@@ -54,7 +55,31 @@ from common.storage import build_storage_uri
def _safe_file_name(value: str) -> str:
return value.strip() or "upload.bin"
"""Sanitize a user-supplied filename for use in an S3 object key.
Rules:
- Strip whitespace
- Reject empty / `.` / `..` (path traversal)
- Reject leading `.` (hidden files)
- Reject `/`, `\\`, and any control character (ASCII < 0x20 or 0x7F)
- Reject leading/trailing whitespace already handled by .strip()
The Jupyter-side editor selection depends on the suffix, so callers
use ``PurePosixPath(safe).suffix`` to recover the extension.
"""
name = value.strip()
if (
not name
or name in {".", ".."}
or name.startswith(".")
or "/" in name
or "\\" in name
or any(ord(char) < 0x20 or ord(char) == 0x7F for char in name)
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="invalid file_name",
)
return name
def _utcnow_naive() -> Any:
@@ -167,11 +192,15 @@ async def create_upload_record(
payload.usage_type,
workspace_artifact_bucket=workspace.artifact_bucket,
)
# Keep the opaque upload id while preserving the original extension.
# Jupyter selects its editor from this suffix, so an extensionless
# object would make notebooks look like generic JSON/text files.
file_extension = PurePosixPath(_safe_file_name(payload.file_name)).suffix.lower()
object_key = f"{payload.workspace_id}/{payload.user_id}/{new_ulid()}{file_extension}"
# 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}"
upload = UploadSessions(
upload_id=new_ulid(),
workspace_id=payload.workspace_id,
@@ -305,7 +334,15 @@ async def upload_bytes_to_session(
usage_type=upload.usage_type,
)
session.add(item)
await session.flush()
try:
await session.flush()
except IntegrityError as exc:
upload.upload_status = "failed"
await session.rollback()
raise HTTPException(
status.HTTP_409_CONFLICT,
"a file with this name already exists at this path; rename and retry",
) from exc
await session.refresh(item)
upload.storage_object_id = item.storage_object_id
upload.upload_status = "completed"