feat: 完善模型平台相关功能
This commit is contained in:
+4
-1
@@ -1,6 +1,9 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PATH="/app/.venv/bin:${PATH}"
|
||||
WORKDIR /app
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.dependencies import database_session
|
||||
from backend.dependencies import (
|
||||
RequestContext,
|
||||
database_session,
|
||||
request_context,
|
||||
)
|
||||
from backend.runtime_client import RuntimeClientError
|
||||
from common.config import settings
|
||||
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
|
||||
@@ -32,6 +36,35 @@ def _b64decode(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(value + padding)
|
||||
|
||||
|
||||
def _b64encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def create_jwt_token(user_id: str, *, expires_seconds: int = 3600) -> str:
|
||||
header = _b64encode(
|
||||
json.dumps(
|
||||
{"alg": JWT_ALGORITHM, "typ": "JWT"},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
)
|
||||
expires_at = int(time.time()) + expires_seconds
|
||||
payload = _b64encode(
|
||||
json.dumps(
|
||||
{"sub": user_id, "exp": expires_at},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
)
|
||||
signing_input = f"{header}.{payload}".encode()
|
||||
signature = _b64encode(
|
||||
hmac.new(
|
||||
JWT_SECRET.encode(),
|
||||
signing_input,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
)
|
||||
return f"{header}.{payload}.{signature}"
|
||||
|
||||
|
||||
def verify_jwt_token(token: str) -> dict:
|
||||
"""Parse a signed JWT from the access_token cookie.
|
||||
|
||||
@@ -168,6 +201,44 @@ async def load_active_membership(
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/api/v1/auth/demo-session")
|
||||
async def create_demo_session(
|
||||
request: Request,
|
||||
response: Response,
|
||||
context: RequestContext = Depends(request_context),
|
||||
) -> dict:
|
||||
"""Issue the short-lived HttpOnly cookie used by the self-hosted UI.
|
||||
|
||||
The normal request-context check still requires an active user/workspace
|
||||
membership. This endpoint is disabled by default and must be explicitly
|
||||
enabled by the deployment configuration.
|
||||
"""
|
||||
if not settings.demo_auth_enabled:
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
expires_seconds = 3600
|
||||
response.set_cookie(
|
||||
"access_token",
|
||||
create_jwt_token(
|
||||
context.user.user_id,
|
||||
expires_seconds=expires_seconds,
|
||||
),
|
||||
max_age=expires_seconds,
|
||||
httponly=True,
|
||||
secure=request.url.scheme == "https",
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
return {
|
||||
"request_id": context.request_id,
|
||||
"data": {
|
||||
"user_id": context.user.user_id,
|
||||
"workspace_id": context.workspace.workspace_id,
|
||||
"expires_at": int(time.time()) + expires_seconds,
|
||||
},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/v1/auth/jupyter")
|
||||
async def verify_jupyter_access(
|
||||
request: Request,
|
||||
|
||||
@@ -95,6 +95,7 @@ async def complete_resource_upload(
|
||||
upload_id,
|
||||
{
|
||||
"usage_type": "data_resource",
|
||||
"file_name": payload.resource_name,
|
||||
"visibility": payload.visibility,
|
||||
"is_immutable": False,
|
||||
},
|
||||
|
||||
@@ -130,12 +130,20 @@ def script_payload(
|
||||
) -> dict[str, Any]:
|
||||
if isinstance(storage_object, dict):
|
||||
relative_path = storage_object.get("relative_path")
|
||||
object_key = storage_object.get("object_key")
|
||||
content_hash = storage_object.get("content_hash")
|
||||
size_bytes = storage_object.get("size_bytes", 0)
|
||||
else:
|
||||
relative_path = storage_object.relative_path
|
||||
object_key = storage_object.object_key
|
||||
content_hash = storage_object.content_hash
|
||||
size_bytes = storage_object.size_bytes
|
||||
workspace_prefix = f"{script.workspace_id}/"
|
||||
jupyter_path = (
|
||||
object_key[len(workspace_prefix):]
|
||||
if object_key and object_key.startswith(workspace_prefix)
|
||||
else object_key
|
||||
)
|
||||
return {
|
||||
"script_id": script.script_id,
|
||||
"workspace_id": script.workspace_id,
|
||||
@@ -146,6 +154,7 @@ def script_payload(
|
||||
"visibility": script.visibility,
|
||||
"status": script.status,
|
||||
"relative_path": relative_path,
|
||||
"jupyter_path": jupyter_path,
|
||||
"content_hash": content_hash,
|
||||
"size_bytes": size_bytes,
|
||||
"created_at": script.created_at.isoformat(),
|
||||
@@ -299,6 +308,7 @@ async def create_script_record(
|
||||
visibility=visibility,
|
||||
is_immutable=False,
|
||||
idempotency_key=f"script:{context.workspace.workspace_id}:{relative_path}",
|
||||
relative_path=relative_path,
|
||||
)
|
||||
object_id = storage_data["storage_object_id"]
|
||||
script = await session.scalar(
|
||||
@@ -653,6 +663,7 @@ async def update_script(
|
||||
f"script-update:{script.script_id}:"
|
||||
f"{hashlib.sha256(content).hexdigest()}"
|
||||
),
|
||||
relative_path=storage_object.relative_path,
|
||||
)
|
||||
script.current_object_id = storage_data["storage_object_id"]
|
||||
script.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
@@ -99,6 +99,7 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]:
|
||||
"storage_backend": item.storage_backend,
|
||||
"usage_type": item.usage_type,
|
||||
"storage_uri": item.storage_uri,
|
||||
"object_key": item.object_key,
|
||||
"relative_path": item.relative_path,
|
||||
"file_name": item.file_name,
|
||||
"file_extension": item.file_extension,
|
||||
@@ -199,12 +200,13 @@ async def create_upload_record(
|
||||
else:
|
||||
upload_id = new_ulid()
|
||||
bucket_name = resolve_bucket(payload.usage_type, workspace=workspace)
|
||||
# Object key is a flat two-level path: workspace id + upload id. The
|
||||
# original file name and content type live in the StorageObjects row
|
||||
# (file_name / mime_type / object_key) — they are not part of the
|
||||
# key itself, so the bucket can be reorganised without rewriting
|
||||
# the database.
|
||||
object_key = f"{payload.workspace_id}/{upload_id}"
|
||||
# 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}/{upload_id}{file_extension}"
|
||||
upload = UploadSessions(
|
||||
upload_id=upload_id,
|
||||
workspace_id=payload.workspace_id,
|
||||
@@ -465,7 +467,21 @@ async def create_server_object(
|
||||
session,
|
||||
request)
|
||||
if upload_result.get("status") == "completed":
|
||||
return {"data": upload_result["storage_object"], "meta": {"reused": True}}
|
||||
existing_data = upload_result["storage_object"]
|
||||
if (
|
||||
payload.relative_path
|
||||
and existing_data
|
||||
and existing_data.get("relative_path") != payload.relative_path
|
||||
):
|
||||
existing_item = await session.get(
|
||||
StorageObjects,
|
||||
existing_data["storage_object_id"],
|
||||
)
|
||||
if existing_item is not None:
|
||||
existing_item.relative_path = payload.relative_path
|
||||
await session.flush()
|
||||
existing_data = storage_payload(existing_item)
|
||||
return {"data": existing_data, "meta": {"reused": True}}
|
||||
|
||||
upload = await session.get(UploadSessions, upload_result["upload_id"])
|
||||
if upload is None:
|
||||
@@ -483,10 +499,13 @@ async def create_server_object(
|
||||
upload.upload_id,
|
||||
CompleteUploadRequest(
|
||||
usage_type=payload.usage_type,
|
||||
file_name=payload.file_name,
|
||||
visibility=payload.visibility,
|
||||
is_immutable=payload.is_immutable),
|
||||
session,
|
||||
request)
|
||||
item.relative_path = payload.relative_path
|
||||
await session.flush()
|
||||
return {"data": storage_payload(item), "meta": {"reused": False}}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user