fix: storage_api.py

This commit is contained in:
tao.chen
2026-08-06 19:02:11 +08:00
parent 5fa61d7146
commit 2894b1f06f
5 changed files with 43 additions and 49 deletions
+1 -1
View File
@@ -239,7 +239,7 @@ async def delete_employee(
.select_from(WorkspaceMembers)
.where(WorkspaceMembers.user_id == user_id)
)
if memberships == 0:
if int(memberships or 0) == 0:
user.status = "disabled"
return {
"request_id": context.request_id,
+9 -1
View File
@@ -24,6 +24,7 @@ from backend.dependencies import database_session
from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token
from common.auth.membership import resolve_is_system_admin
from common.auth.passwords import verify_password
from common.config import settings
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
from common.ids import new_ulid
@@ -41,13 +42,20 @@ COOKIE_SAMESITE = "lax"
def _set_session_cookie(request: Request, response: Response, token: str) -> None:
forwarded_scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
# Operators running behind a TLS-terminating proxy that strips
# X-Forwarded-Proto can opt into forcing the Secure flag via
# ``settings.cookie_force_secure`` — without that override a plain
# HTTP request (no forwarded scheme, no TLS upgrade visible to the
# app) would yield an insecure cookie and modern browsers would
# silently drop it on the HTTPS round trip.
secure = forwarded_scheme == "https" or settings.cookie_force_secure
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=COOKIE_TTL_SECONDS,
path="/",
httponly=True,
secure=forwarded_scheme == "https",
secure=secure,
samesite=COOKIE_SAMESITE,
)
+2 -6
View File
@@ -1,11 +1,9 @@
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
import httpx
from fastapi.routing import APIRoute
from common.config import settings
from common.db import create_database_engine, create_session_factory
@@ -27,7 +25,7 @@ from backend.rclone_rc_client import RcloneRCClient
from backend.schedule_runs import router as schedule_runs_router
from backend.schedules import router as schedules_router
from backend.scripts import router as scripts_router
from backend.storage_api import app as storage_app
from backend.storage_api import router as storage_api_router
@asynccontextmanager
@@ -79,6 +77,4 @@ app.include_router(admin_router)
app.include_router(platform_router)
# Reuse the proven storage endpoints without running another FastAPI service.
for route in storage_app.routes:
if isinstance(route, APIRoute) and route.path.startswith("/internal/"):
app.router.routes.append(route)
app.include_router(storage_api_router, prefix="/internal")
+20 -41
View File
@@ -1,16 +1,16 @@
from __future__ import annotations
import hashlib
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from pathlib import PurePosixPath
from typing import Any, AsyncIterator
from fastapi import Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from common.config import settings
from common.db import create_database_engine, create_session_factory, session_scope
from common.db import session_scope
from common.db.models import (
StorageObjects,
UploadSessions,
@@ -19,14 +19,7 @@ from common.db.models import (
Workspaces,
)
from common.ids import new_ulid
from common.service_app import create_service_app
from common.storage import (
AsyncStorageBackend,
PURPOSE_BUCKETS,
actual_bucket_name,
build_storage_config,
create_storage,
)
from common.storage import actual_bucket_name
from common.storage.schemas import (
CreateUploadRequest,
DownloadUrlRequest,
@@ -126,27 +119,13 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]:
}
@asynccontextmanager
async def lifespan(app: Any) -> AsyncIterator[None]:
engine = create_database_engine(settings.database_url)
app.state.session_factory = create_session_factory(engine)
# Buckets are pre-provisioned by the deployment; the storage layer no
# longer auto-creates them. ``build_storage_config`` picks s3 vs local
# based on ``settings.storage_backend``. The dict is keyed by the
# actual bucket name so ``object_stores[upload.bucket_name]``
# works without a reverse mapping.
app.state.object_stores: dict[str, AsyncStorageBackend] = {
actual_bucket_name(purpose): create_storage(build_storage_config(purpose))
for purpose in PURPOSE_BUCKETS
}
app.state.default_bucket = settings.s3_workspace_bucket
try:
yield
finally:
await engine.dispose()
app = create_service_app(settings.service_name, lifespan=lifespan)
# Internal routes are mounted from main.py via
# ``include_router(router, prefix="/internal")``. The engine, session
# factory, and object_stores live on the main app's lifespan — this
# module only owns the route definitions and the storage-helper
# utilities (``storage_payload``, ``resolve_bucket``, ``BUCKET_FOR_USAGE``
# …) consumed by ``backend.services.storage``.
router = APIRouter(tags=["internal-storage"])
async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
@@ -392,7 +371,7 @@ async def upload_bytes_to_session(
return item
@app.post("/internal/v1/uploads")
@router.post("/v1/uploads")
async def create_upload(
payload: CreateUploadRequest,
request: Request,
@@ -403,7 +382,7 @@ async def create_upload(
}
@app.put("/internal/v1/uploads/{upload_id}")
@router.put("/v1/uploads/{upload_id}")
async def upload_bytes(
upload_id: str, request: Request, session: AsyncSession = Depends(database_session)
) -> dict[str, Any]:
@@ -415,7 +394,7 @@ async def upload_bytes(
return {"data": storage_payload(item)}
@app.post("/internal/v1/uploads/{upload_id}/abort")
@router.post("/v1/uploads/{upload_id}/abort")
async def abort_upload(
upload_id: str, request: Request, session: AsyncSession = Depends(database_session)
) -> dict[str, Any]:
@@ -438,7 +417,7 @@ async def abort_upload(
return {"data": {"upload_id": upload_id, "status": "aborted"}}
@app.post("/internal/v1/objects")
@router.post("/v1/objects")
async def create_server_object(
payload: ServerObjectRequest,
request: Request,
@@ -447,7 +426,7 @@ async def create_server_object(
return await create_server_object_payload(payload, request, session)
@app.post("/internal/v1/objects/{storage_object_id}/download-url")
@router.post("/v1/objects/{storage_object_id}/download-url")
async def create_download_url(
storage_object_id: str,
payload: DownloadUrlRequest,
@@ -458,7 +437,7 @@ async def create_download_url(
return await create_download_url_payload(item, payload, request)
@app.delete("/internal/v1/objects/{storage_object_id}")
@router.delete("/v1/objects/{storage_object_id}")
async def delete_object(
storage_object_id: str,
request: Request,
@@ -468,7 +447,7 @@ async def delete_object(
return await soft_delete_object(storage_object_id, request, session)
@app.post("/internal/v1/objects/{storage_object_id}/restore")
@router.post("/v1/objects/{storage_object_id}/restore")
async def restore_object(
storage_object_id: str,
request: Request,
@@ -517,7 +496,7 @@ async def restore_object(
}
@app.post("/internal/v1/admin/trash/purge")
@router.post("/v1/admin/trash/purge")
async def purge_trash_object(
payload: dict[str, Any],
request: Request,
@@ -562,6 +541,6 @@ async def purge_trash_object(
return {"data": {"storage_object_id": storage_object_id, "purged": True}}
@app.get("/internal/health/storage")
@router.get("/health/storage")
async def internal_health() -> dict[str, str]:
return {"status": "ready", "service": "storage-api"}
+11
View File
@@ -42,6 +42,17 @@ class Settings(BaseSettings):
default=False,
description="Enable the self-hosted UI's short-lived demo session cookie.",
)
cookie_force_secure: bool = Field(
default=False,
description=(
"Force the ``Secure`` flag on the session cookie even when the "
"inbound request scheme is plain HTTP. Enable this when running "
"behind a TLS-terminating reverse proxy that strips or rewrites "
"``X-Forwarded-Proto`` — without it the cookie is written without "
"the Secure flag and modern browsers will refuse to send it back "
"over HTTPS."
),
)
# ── service identity ──────────────────────────────────────────
service_name: str = Field(