fix: storage_api.py
This commit is contained in:
@@ -239,7 +239,7 @@ async def delete_employee(
|
|||||||
.select_from(WorkspaceMembers)
|
.select_from(WorkspaceMembers)
|
||||||
.where(WorkspaceMembers.user_id == user_id)
|
.where(WorkspaceMembers.user_id == user_id)
|
||||||
)
|
)
|
||||||
if memberships == 0:
|
if int(memberships or 0) == 0:
|
||||||
user.status = "disabled"
|
user.status = "disabled"
|
||||||
return {
|
return {
|
||||||
"request_id": context.request_id,
|
"request_id": context.request_id,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from backend.dependencies import database_session
|
|||||||
from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token
|
from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token
|
||||||
from common.auth.membership import resolve_is_system_admin
|
from common.auth.membership import resolve_is_system_admin
|
||||||
from common.auth.passwords import verify_password
|
from common.auth.passwords import verify_password
|
||||||
|
from common.config import settings
|
||||||
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
||||||
from common.ids import new_ulid
|
from common.ids import new_ulid
|
||||||
|
|
||||||
@@ -41,13 +42,20 @@ COOKIE_SAMESITE = "lax"
|
|||||||
|
|
||||||
def _set_session_cookie(request: Request, response: Response, token: str) -> None:
|
def _set_session_cookie(request: Request, response: Response, token: str) -> None:
|
||||||
forwarded_scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
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(
|
response.set_cookie(
|
||||||
key=COOKIE_NAME,
|
key=COOKIE_NAME,
|
||||||
value=token,
|
value=token,
|
||||||
max_age=COOKIE_TTL_SECONDS,
|
max_age=COOKIE_TTL_SECONDS,
|
||||||
path="/",
|
path="/",
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=forwarded_scheme == "https",
|
secure=secure,
|
||||||
samesite=COOKIE_SAMESITE,
|
samesite=COOKIE_SAMESITE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi.routing import APIRoute
|
|
||||||
|
|
||||||
from common.config import settings
|
from common.config import settings
|
||||||
from common.db import create_database_engine, create_session_factory
|
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.schedule_runs import router as schedule_runs_router
|
||||||
from backend.schedules import router as schedules_router
|
from backend.schedules import router as schedules_router
|
||||||
from backend.scripts import router as scripts_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
|
@asynccontextmanager
|
||||||
@@ -79,6 +77,4 @@ app.include_router(admin_router)
|
|||||||
app.include_router(platform_router)
|
app.include_router(platform_router)
|
||||||
|
|
||||||
# Reuse the proven storage endpoints without running another FastAPI service.
|
# Reuse the proven storage endpoints without running another FastAPI service.
|
||||||
for route in storage_app.routes:
|
app.include_router(storage_api_router, prefix="/internal")
|
||||||
if isinstance(route, APIRoute) and route.path.startswith("/internal/"):
|
|
||||||
app.router.routes.append(route)
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import PurePosixPath
|
||||||
from typing import Any, AsyncIterator
|
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 import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.config import settings
|
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 (
|
from common.db.models import (
|
||||||
StorageObjects,
|
StorageObjects,
|
||||||
UploadSessions,
|
UploadSessions,
|
||||||
@@ -19,14 +19,7 @@ from common.db.models import (
|
|||||||
Workspaces,
|
Workspaces,
|
||||||
)
|
)
|
||||||
from common.ids import new_ulid
|
from common.ids import new_ulid
|
||||||
from common.service_app import create_service_app
|
from common.storage import actual_bucket_name
|
||||||
from common.storage import (
|
|
||||||
AsyncStorageBackend,
|
|
||||||
PURPOSE_BUCKETS,
|
|
||||||
actual_bucket_name,
|
|
||||||
build_storage_config,
|
|
||||||
create_storage,
|
|
||||||
)
|
|
||||||
from common.storage.schemas import (
|
from common.storage.schemas import (
|
||||||
CreateUploadRequest,
|
CreateUploadRequest,
|
||||||
DownloadUrlRequest,
|
DownloadUrlRequest,
|
||||||
@@ -126,27 +119,13 @@ def storage_payload(item: StorageObjects) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
# Internal routes are mounted from main.py via
|
||||||
async def lifespan(app: Any) -> AsyncIterator[None]:
|
# ``include_router(router, prefix="/internal")``. The engine, session
|
||||||
engine = create_database_engine(settings.database_url)
|
# factory, and object_stores live on the main app's lifespan — this
|
||||||
app.state.session_factory = create_session_factory(engine)
|
# module only owns the route definitions and the storage-helper
|
||||||
# Buckets are pre-provisioned by the deployment; the storage layer no
|
# utilities (``storage_payload``, ``resolve_bucket``, ``BUCKET_FOR_USAGE``
|
||||||
# longer auto-creates them. ``build_storage_config`` picks s3 vs local
|
# …) consumed by ``backend.services.storage``.
|
||||||
# based on ``settings.storage_backend``. The dict is keyed by the
|
router = APIRouter(tags=["internal-storage"])
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
|
async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
|
||||||
@@ -392,7 +371,7 @@ async def upload_bytes_to_session(
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
@app.post("/internal/v1/uploads")
|
@router.post("/v1/uploads")
|
||||||
async def create_upload(
|
async def create_upload(
|
||||||
payload: CreateUploadRequest,
|
payload: CreateUploadRequest,
|
||||||
request: Request,
|
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(
|
async def upload_bytes(
|
||||||
upload_id: str, request: Request, session: AsyncSession = Depends(database_session)
|
upload_id: str, request: Request, session: AsyncSession = Depends(database_session)
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -415,7 +394,7 @@ async def upload_bytes(
|
|||||||
return {"data": storage_payload(item)}
|
return {"data": storage_payload(item)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/internal/v1/uploads/{upload_id}/abort")
|
@router.post("/v1/uploads/{upload_id}/abort")
|
||||||
async def abort_upload(
|
async def abort_upload(
|
||||||
upload_id: str, request: Request, session: AsyncSession = Depends(database_session)
|
upload_id: str, request: Request, session: AsyncSession = Depends(database_session)
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -438,7 +417,7 @@ async def abort_upload(
|
|||||||
return {"data": {"upload_id": upload_id, "status": "aborted"}}
|
return {"data": {"upload_id": upload_id, "status": "aborted"}}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/internal/v1/objects")
|
@router.post("/v1/objects")
|
||||||
async def create_server_object(
|
async def create_server_object(
|
||||||
payload: ServerObjectRequest,
|
payload: ServerObjectRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -447,7 +426,7 @@ async def create_server_object(
|
|||||||
return await create_server_object_payload(payload, request, session)
|
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(
|
async def create_download_url(
|
||||||
storage_object_id: str,
|
storage_object_id: str,
|
||||||
payload: DownloadUrlRequest,
|
payload: DownloadUrlRequest,
|
||||||
@@ -458,7 +437,7 @@ async def create_download_url(
|
|||||||
return await create_download_url_payload(item, payload, request)
|
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(
|
async def delete_object(
|
||||||
storage_object_id: str,
|
storage_object_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -468,7 +447,7 @@ async def delete_object(
|
|||||||
return await soft_delete_object(storage_object_id, request, session)
|
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(
|
async def restore_object(
|
||||||
storage_object_id: str,
|
storage_object_id: str,
|
||||||
request: Request,
|
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(
|
async def purge_trash_object(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -562,6 +541,6 @@ async def purge_trash_object(
|
|||||||
return {"data": {"storage_object_id": storage_object_id, "purged": True}}
|
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]:
|
async def internal_health() -> dict[str, str]:
|
||||||
return {"status": "ready", "service": "storage-api"}
|
return {"status": "ready", "service": "storage-api"}
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ class Settings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
description="Enable the self-hosted UI's short-lived demo session cookie.",
|
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 identity ──────────────────────────────────────────
|
||||||
service_name: str = Field(
|
service_name: str = Field(
|
||||||
|
|||||||
Reference in New Issue
Block a user