아바타 저장소 승격 계약을 완성
This commit is contained in:
parent
ac9b702688
commit
ccdcfcd2f5
36 changed files with 14734 additions and 222 deletions
|
|
@ -465,6 +465,26 @@ class Settings(BaseSettings):
|
|||
default="uploads",
|
||||
validation_alias="USER_UPLOAD_DIR",
|
||||
)
|
||||
user_upload_manifest_required: bool = Field(
|
||||
default=False,
|
||||
validation_alias="USER_UPLOAD_MANIFEST_REQUIRED",
|
||||
)
|
||||
user_upload_manifest_path: str = Field(
|
||||
default="",
|
||||
validation_alias="USER_UPLOAD_MANIFEST_PATH",
|
||||
)
|
||||
user_upload_manifest_sha256: str = Field(
|
||||
default="",
|
||||
validation_alias="USER_UPLOAD_MANIFEST_SHA256",
|
||||
)
|
||||
user_upload_write_freeze_path: str = Field(
|
||||
default="",
|
||||
validation_alias="USER_UPLOAD_WRITE_FREEZE_PATH",
|
||||
)
|
||||
public_runtime_db_target_sha256: str = Field(
|
||||
default="",
|
||||
validation_alias="PUBLIC_RUNTIME_DB_TARGET_SHA256",
|
||||
)
|
||||
|
||||
# ── 운영 메일 알림 ─────────────────────────────────────
|
||||
notification_email_provider: Literal["disabled", "smtp"] = Field(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from typing import Any, AsyncIterator, Optional, Sequence
|
|||
import asyncpg
|
||||
|
||||
from .config import settings
|
||||
from .upload_storage import connected_database_target_sha256
|
||||
|
||||
# 전역 풀 핸들. main.py lifespan 에서 init/close.
|
||||
_pool: Optional[asyncpg.Pool] = None
|
||||
|
|
@ -27,6 +28,13 @@ async def _init_connection(conn: asyncpg.Connection) -> None:
|
|||
- vector(1024): pgvector. 런타임 인코딩은 RAG 경로에서 처리(여기선 텍스트 캐스트 허용).
|
||||
TODO: pgvector 바이너리 코덱 등록(register_vector) — RAG 라우터 구현 시 BGE-M3 1024d 연동.
|
||||
"""
|
||||
if settings.user_upload_manifest_required:
|
||||
actual_database_target_sha256 = await connected_database_target_sha256(conn)
|
||||
if (
|
||||
actual_database_target_sha256
|
||||
!= settings.public_runtime_db_target_sha256
|
||||
):
|
||||
raise RuntimeError("connected public runtime database target drift")
|
||||
await conn.set_type_codec(
|
||||
"jsonb",
|
||||
encoder=lambda v: json.dumps(v, ensure_ascii=False),
|
||||
|
|
|
|||
|
|
@ -9,17 +9,16 @@ from __future__ import annotations
|
|||
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import __version__
|
||||
from .access_logging import install_uvicorn_access_log_redaction
|
||||
from .auth_sessions import ensure_runtime_tables
|
||||
from .config import settings
|
||||
from .db import acquire, close_pool, healthcheck, init_pool
|
||||
from .db import acquire, close_pool, get_pool, healthcheck, init_pool
|
||||
from .engine_client import engine_client
|
||||
from .persona_repository import materialize_seed_personas
|
||||
from .session_persistence import ensure_review_tables
|
||||
|
|
@ -63,6 +62,13 @@ from .services import (
|
|||
supervision_research_producer,
|
||||
)
|
||||
from .services.voice import voice_service
|
||||
from .upload_runtime import (
|
||||
FlatPublicAvatarStaticFiles,
|
||||
public_avatar_root,
|
||||
upload_manifest_proof,
|
||||
upload_write_freeze_gate,
|
||||
validate_runtime_upload_database_state,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -83,8 +89,13 @@ async def lifespan(app: FastAPI):
|
|||
supervision_research_schema_ready = False
|
||||
continuous_improvement_schema_ready = False
|
||||
multimodal_alliance_schema_ready = False
|
||||
app.state.upload_database_proof = None
|
||||
try:
|
||||
await init_pool()
|
||||
if upload_manifest_proof is not None:
|
||||
app.state.upload_database_proof = (
|
||||
await validate_runtime_upload_database_state(get_pool())
|
||||
)
|
||||
await ensure_runtime_tables()
|
||||
await ensure_review_tables()
|
||||
await ensure_notification_tables()
|
||||
|
|
@ -190,7 +201,7 @@ async def lifespan(app: FastAPI):
|
|||
await materialize_seed_personas()
|
||||
await admin_routes.apply_engine_config_from_store()
|
||||
except Exception as exc: # DB 없어도 store 폴백으로 1턴 동작 (dev/로컬)
|
||||
if settings.environment != "dev":
|
||||
if settings.environment != "dev" or upload_manifest_proof is not None:
|
||||
raise
|
||||
import logging
|
||||
|
||||
|
|
@ -245,11 +256,11 @@ app.add_middleware(
|
|||
|
||||
# TODO: Presidio PII 마스킹 미들웨어 (외부 LLM 경로 진입 전 하드 게이트, R7/F-03)
|
||||
|
||||
_upload_root = Path(settings.user_upload_dir)
|
||||
if not _upload_root.is_absolute():
|
||||
_upload_root = Path.cwd() / _upload_root
|
||||
_upload_root.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(_upload_root)), name="uploads")
|
||||
app.mount(
|
||||
"/uploads/profile-avatars",
|
||||
FlatPublicAvatarStaticFiles(directory=str(public_avatar_root)),
|
||||
name="profile-avatars",
|
||||
)
|
||||
|
||||
app.include_router(auth_routes.router)
|
||||
app.include_router(calibration_transfer_routes.router)
|
||||
|
|
@ -280,8 +291,25 @@ async def health() -> dict[str, object]:
|
|||
db_ok = await healthcheck()
|
||||
engine = await engine_client.health_detail()
|
||||
engine_ok = bool(engine.get("ok"))
|
||||
upload_freeze = asdict(upload_write_freeze_gate.status())
|
||||
upload_database_proof = getattr(app.state, "upload_database_proof", None)
|
||||
upload_write_gate_ok = (
|
||||
not bool(upload_freeze["capable"]) or bool(upload_freeze["valid"])
|
||||
)
|
||||
upload_database_ok = (
|
||||
upload_manifest_proof is None
|
||||
or (
|
||||
upload_database_proof is not None
|
||||
and upload_database_proof.database_target_sha256
|
||||
== upload_manifest_proof.database_target_sha256
|
||||
)
|
||||
)
|
||||
return {
|
||||
"status": "ok" if db_ok and engine_ok else "degraded",
|
||||
"status": (
|
||||
"ok"
|
||||
if db_ok and engine_ok and upload_write_gate_ok and upload_database_ok
|
||||
else "degraded"
|
||||
),
|
||||
"version": __version__,
|
||||
"environment": settings.environment,
|
||||
"db": db_ok,
|
||||
|
|
@ -293,4 +321,75 @@ async def health() -> dict[str, object]:
|
|||
),
|
||||
"default_engine": engine.get("default_engine"),
|
||||
"live_client_engine": engine.get("live_client_engine"),
|
||||
"upload_write_freeze": upload_freeze,
|
||||
"upload_manifest": (
|
||||
{
|
||||
"required": True,
|
||||
"validated": True,
|
||||
"manifest_sha256": upload_manifest_proof.manifest_sha256,
|
||||
"database_target_sha256": (
|
||||
upload_manifest_proof.database_target_sha256
|
||||
),
|
||||
"preserved_object_count": (
|
||||
upload_manifest_proof.preserved_object_count
|
||||
),
|
||||
"preserved_total_size_bytes": (
|
||||
upload_manifest_proof.preserved_total_size_bytes
|
||||
),
|
||||
"preserved_decode_valid_count": (
|
||||
upload_manifest_proof.preserved_decode_valid_count
|
||||
),
|
||||
"preserved_decode_invalid_count": (
|
||||
upload_manifest_proof.preserved_decode_invalid_count
|
||||
),
|
||||
"required_decode_invalid_object_count": (
|
||||
upload_manifest_proof.required_decode_invalid_object_count
|
||||
),
|
||||
"required_decode_invalid_reference_count": (
|
||||
upload_manifest_proof.required_decode_invalid_reference_count
|
||||
),
|
||||
"forensic_fallback_active": (
|
||||
upload_manifest_proof.required_decode_invalid_reference_count
|
||||
> 0
|
||||
or (
|
||||
upload_database_proof is not None
|
||||
and upload_database_proof.current_references.decode_invalid_reference_count
|
||||
> 0
|
||||
)
|
||||
),
|
||||
"preserved_object_set_sha256": (
|
||||
upload_manifest_proof.preserved_object_set_sha256
|
||||
),
|
||||
"required_object_count": upload_manifest_proof.required_object_count,
|
||||
"reference_set_sha256": upload_manifest_proof.reference_set_sha256,
|
||||
"runtime_database_validated": upload_database_ok,
|
||||
"current_object_count": (
|
||||
upload_database_proof.current_references.object_count
|
||||
if upload_database_proof is not None
|
||||
else None
|
||||
),
|
||||
"current_reference_count": (
|
||||
upload_database_proof.current_references.reference_count
|
||||
if upload_database_proof is not None
|
||||
else None
|
||||
),
|
||||
"current_reference_set_sha256": (
|
||||
upload_database_proof.current_references.reference_set_sha256
|
||||
if upload_database_proof is not None
|
||||
else None
|
||||
),
|
||||
"current_decode_invalid_object_count": (
|
||||
upload_database_proof.current_references.decode_invalid_object_count
|
||||
if upload_database_proof is not None
|
||||
else None
|
||||
),
|
||||
"current_decode_invalid_reference_count": (
|
||||
upload_database_proof.current_references.decode_invalid_reference_count
|
||||
if upload_database_proof is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
if upload_manifest_proof is not None
|
||||
else {"required": False, "validated": False}
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ from __future__ import annotations
|
|||
|
||||
import secrets
|
||||
import time
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import Iterator, Literal
|
||||
|
||||
import re
|
||||
|
||||
|
|
@ -27,12 +30,23 @@ from ..runtime_policy import require_runtime_fallback_allowed
|
|||
from ..services.phase3_kpi_contract import PREPOST_MEASURE_NAMES, PREPOST_TIMEPOINTS
|
||||
from ..services.support_tickets import support_ticket_fingerprint
|
||||
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
|
||||
from ..upload_runtime import (
|
||||
assert_public_avatar_url_exists,
|
||||
public_avatar_root,
|
||||
upload_write_freeze_gate,
|
||||
)
|
||||
from ..upload_storage import (
|
||||
PUBLIC_AVATAR_MAX_BYTES,
|
||||
UploadWriteFrozen,
|
||||
assert_path_without_reparse,
|
||||
inspect_public_avatar_image,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
TERMS_VERSION = "terms-draft-2026-06-27"
|
||||
PRIVACY_VERSION = "privacy-draft-2026-06-27"
|
||||
AVATAR_MAX_BYTES = 3 * 1024 * 1024
|
||||
AVATAR_MAX_BYTES = PUBLIC_AVATAR_MAX_BYTES
|
||||
AVATAR_CONTENT_TYPES = {
|
||||
"image/png": ("png", b"\x89PNG\r\n\x1a\n"),
|
||||
"image/jpeg": ("jpg", b"\xff\xd8\xff"),
|
||||
|
|
@ -430,12 +444,61 @@ def _onboarding_required(managed) -> bool:
|
|||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
root = Path(settings.user_upload_dir)
|
||||
if not root.is_absolute():
|
||||
root = Path.cwd() / root
|
||||
avatar_root = root / "profile-avatars"
|
||||
avatar_root.mkdir(parents=True, exist_ok=True)
|
||||
return avatar_root
|
||||
try:
|
||||
assert_path_without_reparse(public_avatar_root, "public avatar upload root")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="profile_upload_storage_unavailable",
|
||||
) from exc
|
||||
if not public_avatar_root.is_dir():
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="profile_upload_storage_unavailable",
|
||||
)
|
||||
return public_avatar_root
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _profile_avatar_write_lease() -> Iterator[None]:
|
||||
try:
|
||||
with upload_write_freeze_gate.write_lease():
|
||||
yield
|
||||
except UploadWriteFrozen as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="profile_upload_writes_frozen",
|
||||
headers={"Retry-After": "30"},
|
||||
) from exc
|
||||
|
||||
|
||||
def _assert_public_avatar_url_exists(value: str | None) -> None:
|
||||
if value is None or not value.startswith("/uploads/"):
|
||||
return
|
||||
try:
|
||||
assert_public_avatar_url_exists(value)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail="avatar_upload_not_found",
|
||||
) from exc
|
||||
|
||||
|
||||
def _write_new_avatar_atomically(target: Path, content: bytes) -> None:
|
||||
assert_path_without_reparse(target.parent, "public avatar upload root")
|
||||
if not target.parent.is_dir():
|
||||
raise ValueError("public avatar upload root is unavailable")
|
||||
temporary = target.with_name(f".{target.name}.{secrets.token_hex(8)}.uploading")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(content)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
# A hard-link publishes one fully flushed inode and fails rather than
|
||||
# replacing an existing random-name target.
|
||||
os.link(temporary, target, follow_symlinks=False)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _validated_avatar_extension(content_type: str, content: bytes) -> str:
|
||||
|
|
@ -453,9 +516,24 @@ def _validated_avatar_extension(content_type: str, content: bytes) -> str:
|
|||
)
|
||||
elif not content.startswith(magic):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
|
||||
if not inspect_public_avatar_image(
|
||||
f"profile-avatars/upload.{ext}",
|
||||
content,
|
||||
).valid:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
|
||||
return ext
|
||||
|
||||
|
||||
def _normalized_upload_user_id(value: str) -> str:
|
||||
try:
|
||||
return str(uuid.UUID(value))
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="profile_upload_identity_unavailable",
|
||||
) from exc
|
||||
|
||||
|
||||
async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse:
|
||||
managed = await get_managed_user(principal.user_id)
|
||||
return UserProfileResponse(
|
||||
|
|
@ -518,41 +596,58 @@ async def patch_me(
|
|||
body: UserProfilePatch, principal: CurrentPrincipal
|
||||
) -> UserProfileResponse:
|
||||
profile = await _profile_for(principal)
|
||||
updated = await update_managed_user(
|
||||
principal.user_id,
|
||||
ManagedUserPatch(
|
||||
display_name=body.display_name
|
||||
if body.display_name is not None
|
||||
else profile.display_name,
|
||||
affiliation=body.affiliation
|
||||
if body.affiliation is not None
|
||||
else profile.affiliation,
|
||||
legal_name=body.legal_name
|
||||
if body.legal_name is not None
|
||||
else profile.legal_name,
|
||||
department=body.department
|
||||
if body.department is not None
|
||||
else profile.department,
|
||||
grade_level=body.grade_level
|
||||
if body.grade_level is not None
|
||||
else profile.grade_level,
|
||||
phone=body.phone if body.phone is not None else profile.phone,
|
||||
contact_address=(
|
||||
body.contact_address
|
||||
if body.contact_address is not None
|
||||
else profile.contact_address
|
||||
avatar_requested = body.avatar_url is not None
|
||||
lease = _profile_avatar_write_lease() if avatar_requested else nullcontext()
|
||||
with lease:
|
||||
_assert_public_avatar_url_exists(body.avatar_url if avatar_requested else None)
|
||||
updated = await update_managed_user(
|
||||
principal.user_id,
|
||||
ManagedUserPatch(
|
||||
display_name=(
|
||||
body.display_name
|
||||
if body.display_name is not None
|
||||
else profile.display_name
|
||||
),
|
||||
affiliation=(
|
||||
body.affiliation
|
||||
if body.affiliation is not None
|
||||
else profile.affiliation
|
||||
),
|
||||
legal_name=(
|
||||
body.legal_name
|
||||
if body.legal_name is not None
|
||||
else profile.legal_name
|
||||
),
|
||||
department=(
|
||||
body.department
|
||||
if body.department is not None
|
||||
else profile.department
|
||||
),
|
||||
grade_level=(
|
||||
body.grade_level
|
||||
if body.grade_level is not None
|
||||
else profile.grade_level
|
||||
),
|
||||
phone=body.phone if body.phone is not None else profile.phone,
|
||||
contact_address=(
|
||||
body.contact_address
|
||||
if body.contact_address is not None
|
||||
else profile.contact_address
|
||||
),
|
||||
nickname=(
|
||||
body.nickname if body.nickname is not None else profile.nickname
|
||||
),
|
||||
self_introduction=(
|
||||
body.self_introduction
|
||||
if body.self_introduction is not None
|
||||
else profile.self_introduction
|
||||
),
|
||||
# None is deliberate: update_managed_user uses COALESCE so an
|
||||
# unrelated profile PATCH cannot restore the stale avatar read
|
||||
# above over a concurrent avatar change.
|
||||
avatar_url=body.avatar_url if avatar_requested else None,
|
||||
),
|
||||
nickname=body.nickname if body.nickname is not None else profile.nickname,
|
||||
self_introduction=(
|
||||
body.self_introduction
|
||||
if body.self_introduction is not None
|
||||
else profile.self_introduction
|
||||
),
|
||||
avatar_url=body.avatar_url
|
||||
if body.avatar_url is not None
|
||||
else profile.avatar_url,
|
||||
),
|
||||
)
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
return await _profile_for(principal)
|
||||
|
|
@ -563,34 +658,29 @@ async def upload_my_avatar(
|
|||
principal: CurrentPrincipal,
|
||||
file: UploadFile = File(...),
|
||||
) -> AvatarUploadResponse:
|
||||
content_type = (file.content_type or "").strip().lower()
|
||||
content = await file.read(AVATAR_MAX_BYTES + 1)
|
||||
await file.close()
|
||||
if not content:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty_avatar_file")
|
||||
if len(content) > AVATAR_MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large"
|
||||
with _profile_avatar_write_lease():
|
||||
normalized_user_id = _normalized_upload_user_id(principal.user_id)
|
||||
content_type = (file.content_type or "").strip().lower()
|
||||
content = await file.read(AVATAR_MAX_BYTES + 1)
|
||||
await file.close()
|
||||
if not content:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty_avatar_file")
|
||||
if len(content) > AVATAR_MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large"
|
||||
)
|
||||
ext = _validated_avatar_extension(content_type, content)
|
||||
|
||||
root = _upload_root()
|
||||
filename = f"{normalized_user_id}-{secrets.token_urlsafe(10)}.{ext}"
|
||||
target = root / filename
|
||||
_write_new_avatar_atomically(target, content)
|
||||
avatar_url = f"/uploads/profile-avatars/{filename}"
|
||||
return AvatarUploadResponse(
|
||||
avatar_url=avatar_url,
|
||||
content_type=content_type.split(";", 1)[0],
|
||||
size_bytes=len(content),
|
||||
)
|
||||
ext = _validated_avatar_extension(content_type, content)
|
||||
|
||||
root = _upload_root()
|
||||
for existing in root.glob(f"{principal.user_id}-*.png"):
|
||||
existing.unlink(missing_ok=True)
|
||||
for existing in root.glob(f"{principal.user_id}-*.jpg"):
|
||||
existing.unlink(missing_ok=True)
|
||||
for existing in root.glob(f"{principal.user_id}-*.webp"):
|
||||
existing.unlink(missing_ok=True)
|
||||
|
||||
filename = f"{principal.user_id}-{secrets.token_urlsafe(10)}.{ext}"
|
||||
target = root / filename
|
||||
target.write_bytes(content)
|
||||
avatar_url = f"/uploads/profile-avatars/{filename}"
|
||||
return AvatarUploadResponse(
|
||||
avatar_url=avatar_url,
|
||||
content_type=content_type.split(";", 1)[0],
|
||||
size_bytes=len(content),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/onboarding", response_model=UserProfileResponse)
|
||||
|
|
@ -604,24 +694,30 @@ async def complete_onboarding(
|
|||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="privacy_not_accepted")
|
||||
|
||||
display_name = body.nickname.strip()
|
||||
updated = await update_managed_user(
|
||||
principal.user_id,
|
||||
ManagedUserPatch(
|
||||
display_name=display_name,
|
||||
affiliation=body.affiliation,
|
||||
legal_name=body.legal_name,
|
||||
department=body.department,
|
||||
grade_level=body.grade_level,
|
||||
phone=body.phone,
|
||||
contact_address=body.contact_address,
|
||||
nickname=body.nickname,
|
||||
self_introduction=body.self_introduction,
|
||||
avatar_url=body.avatar_url,
|
||||
complete_onboarding=True,
|
||||
terms_version=TERMS_VERSION,
|
||||
privacy_version=PRIVACY_VERSION,
|
||||
),
|
||||
)
|
||||
# Onboarding always writes avatar_url (including clearing/replacing an
|
||||
# existing upload), so the entire DB update participates in cutover drain.
|
||||
with _profile_avatar_write_lease():
|
||||
_assert_public_avatar_url_exists(
|
||||
body.avatar_url if body.avatar_url.startswith("/uploads/") else None
|
||||
)
|
||||
updated = await update_managed_user(
|
||||
principal.user_id,
|
||||
ManagedUserPatch(
|
||||
display_name=display_name,
|
||||
affiliation=body.affiliation,
|
||||
legal_name=body.legal_name,
|
||||
department=body.department,
|
||||
grade_level=body.grade_level,
|
||||
phone=body.phone,
|
||||
contact_address=body.contact_address,
|
||||
nickname=body.nickname,
|
||||
self_introduction=body.self_introduction,
|
||||
avatar_url=body.avatar_url,
|
||||
complete_onboarding=True,
|
||||
terms_version=TERMS_VERSION,
|
||||
privacy_version=PRIVACY_VERSION,
|
||||
),
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,130 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from . import main
|
||||
from .upload_storage import UploadWriteFreezeStatus
|
||||
|
||||
|
||||
class EngineHealthContractTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_health_requires_and_exposes_lifespan_database_proof(self) -> None:
|
||||
target_sha256 = "a" * 64
|
||||
manifest = SimpleNamespace(
|
||||
manifest_sha256="b" * 64,
|
||||
database_target_sha256=target_sha256,
|
||||
preserved_object_count=93,
|
||||
preserved_total_size_bytes=52973,
|
||||
preserved_decode_valid_count=3,
|
||||
preserved_decode_invalid_count=90,
|
||||
required_decode_invalid_object_count=0,
|
||||
required_decode_invalid_reference_count=0,
|
||||
preserved_object_set_sha256="c" * 64,
|
||||
required_object_count=8,
|
||||
reference_set_sha256="d" * 64,
|
||||
)
|
||||
runtime_proof = SimpleNamespace(
|
||||
database_target_sha256=target_sha256,
|
||||
current_references=SimpleNamespace(
|
||||
object_count=8,
|
||||
reference_count=8,
|
||||
reference_set_sha256="e" * 64,
|
||||
decode_invalid_object_count=6,
|
||||
decode_invalid_reference_count=6,
|
||||
),
|
||||
)
|
||||
with (
|
||||
patch.object(main, "upload_manifest_proof", manifest),
|
||||
patch.object(main, "healthcheck", AsyncMock(return_value=True)),
|
||||
patch.object(
|
||||
main.engine_client,
|
||||
"health_detail",
|
||||
AsyncMock(return_value={"ok": True}),
|
||||
),
|
||||
patch.object(
|
||||
main.app.state,
|
||||
"upload_database_proof",
|
||||
runtime_proof,
|
||||
create=True,
|
||||
),
|
||||
):
|
||||
response = await main.health()
|
||||
|
||||
self.assertEqual("ok", response["status"])
|
||||
self.assertTrue(response["upload_manifest"]["runtime_database_validated"])
|
||||
self.assertEqual(8, response["upload_manifest"]["current_object_count"])
|
||||
self.assertEqual(
|
||||
52973,
|
||||
response["upload_manifest"]["preserved_total_size_bytes"],
|
||||
)
|
||||
self.assertEqual(8, response["upload_manifest"]["current_reference_count"])
|
||||
self.assertTrue(response["upload_manifest"]["forensic_fallback_active"])
|
||||
self.assertEqual(
|
||||
6,
|
||||
response["upload_manifest"]["current_decode_invalid_reference_count"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"e" * 64,
|
||||
response["upload_manifest"]["current_reference_set_sha256"],
|
||||
)
|
||||
|
||||
async def test_health_is_degraded_for_invalid_active_upload_freeze(self) -> None:
|
||||
engine_detail = {"ok": True, "detail": "ready"}
|
||||
invalid_freeze = UploadWriteFreezeStatus(
|
||||
capable=True,
|
||||
active=True,
|
||||
valid=False,
|
||||
in_flight=0,
|
||||
path_sha256="a" * 64,
|
||||
token_sha256=None,
|
||||
)
|
||||
with (
|
||||
patch.object(main, "healthcheck", AsyncMock(return_value=True)),
|
||||
patch.object(
|
||||
main.engine_client,
|
||||
"health_detail",
|
||||
AsyncMock(return_value=engine_detail),
|
||||
),
|
||||
patch.object(
|
||||
main.upload_write_freeze_gate,
|
||||
"status",
|
||||
return_value=invalid_freeze,
|
||||
),
|
||||
):
|
||||
response = await main.health()
|
||||
|
||||
self.assertEqual("degraded", response["status"])
|
||||
self.assertFalse(response["upload_write_freeze"]["valid"])
|
||||
|
||||
async def test_health_stays_ok_for_valid_active_maintenance_freeze(self) -> None:
|
||||
engine_detail = {"ok": True, "detail": "ready"}
|
||||
valid_freeze = UploadWriteFreezeStatus(
|
||||
capable=True,
|
||||
active=True,
|
||||
valid=True,
|
||||
in_flight=0,
|
||||
path_sha256="a" * 64,
|
||||
token_sha256="b" * 64,
|
||||
)
|
||||
with (
|
||||
patch.object(main, "healthcheck", AsyncMock(return_value=True)),
|
||||
patch.object(
|
||||
main.engine_client,
|
||||
"health_detail",
|
||||
AsyncMock(return_value=engine_detail),
|
||||
),
|
||||
patch.object(
|
||||
main.upload_write_freeze_gate,
|
||||
"status",
|
||||
return_value=valid_freeze,
|
||||
),
|
||||
):
|
||||
response = await main.health()
|
||||
|
||||
self.assertEqual("ok", response["status"])
|
||||
self.assertTrue(response["upload_write_freeze"]["active"])
|
||||
|
||||
async def test_health_is_degraded_when_live_client_lane_is_unready(self) -> None:
|
||||
engine_detail = {
|
||||
"ok": False,
|
||||
|
|
|
|||
1230
apps/api/app/test_upload_storage_contract.py
Normal file
1230
apps/api/app/test_upload_storage_contract.py
Normal file
File diff suppressed because it is too large
Load diff
228
apps/api/app/upload_runtime.py
Normal file
228
apps/api/app/upload_runtime.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""Configured public-avatar storage boundary used by API routes and health."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.responses import Response
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from .config import settings
|
||||
from .upload_storage import (
|
||||
CurrentAvatarReferenceProof,
|
||||
PUBLIC_AVATAR_DIRECTORY,
|
||||
UploadManifestProof,
|
||||
UploadWriteFreezeGate,
|
||||
assert_path_without_reparse,
|
||||
assert_regular_file_without_reparse,
|
||||
connected_database_target_sha256,
|
||||
is_runtime_generated_public_avatar_relative_path,
|
||||
public_avatar_url_to_relative_path,
|
||||
public_avatar_relative_path_sha256,
|
||||
read_decodable_public_avatar_object,
|
||||
validate_current_avatar_references,
|
||||
validate_upload_manifest,
|
||||
)
|
||||
|
||||
|
||||
def _configured_upload_root() -> Path:
|
||||
configured = Path(settings.user_upload_dir)
|
||||
if not configured.is_absolute():
|
||||
if settings.user_upload_manifest_required:
|
||||
raise RuntimeError("public USER_UPLOAD_DIR must be absolute")
|
||||
configured = Path.cwd() / configured
|
||||
if settings.user_upload_manifest_required:
|
||||
try:
|
||||
assert_path_without_reparse(configured, "public USER_UPLOAD_DIR")
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
return configured.resolve(strict=False)
|
||||
|
||||
|
||||
upload_root = _configured_upload_root()
|
||||
upload_manifest_proof: UploadManifestProof | None = None
|
||||
|
||||
if settings.user_upload_manifest_required:
|
||||
if not upload_root.is_dir():
|
||||
raise RuntimeError("initialized public USER_UPLOAD_DIR does not exist")
|
||||
if not settings.user_upload_manifest_path:
|
||||
raise RuntimeError("USER_UPLOAD_MANIFEST_PATH is required")
|
||||
if len(settings.user_upload_manifest_sha256) != 64:
|
||||
raise RuntimeError("USER_UPLOAD_MANIFEST_SHA256 is required")
|
||||
if not settings.user_upload_write_freeze_path:
|
||||
raise RuntimeError("USER_UPLOAD_WRITE_FREEZE_PATH is required")
|
||||
freeze_path = Path(settings.user_upload_write_freeze_path)
|
||||
if not freeze_path.is_absolute():
|
||||
raise RuntimeError("USER_UPLOAD_WRITE_FREEZE_PATH must be absolute")
|
||||
upload_manifest_proof = validate_upload_manifest(
|
||||
upload_root=upload_root,
|
||||
manifest_path=Path(settings.user_upload_manifest_path),
|
||||
expected_manifest_sha256=settings.user_upload_manifest_sha256,
|
||||
expected_write_freeze_path=freeze_path,
|
||||
expected_database_target_sha256=settings.public_runtime_db_target_sha256,
|
||||
verify_preserved_objects=True,
|
||||
reject_unbound_extras=False,
|
||||
)
|
||||
else:
|
||||
upload_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
public_avatar_root = upload_root / PUBLIC_AVATAR_DIRECTORY
|
||||
if settings.user_upload_manifest_required:
|
||||
if not public_avatar_root.is_dir():
|
||||
raise RuntimeError("initialized public avatar directory does not exist")
|
||||
try:
|
||||
assert_path_without_reparse(
|
||||
public_avatar_root,
|
||||
"initialized public avatar directory",
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
else:
|
||||
public_avatar_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
configured_freeze_path = (
|
||||
Path(settings.user_upload_write_freeze_path)
|
||||
if settings.user_upload_write_freeze_path
|
||||
else None
|
||||
)
|
||||
upload_write_freeze_gate = UploadWriteFreezeGate(configured_freeze_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeUploadDatabaseProof:
|
||||
database_target_sha256: str
|
||||
current_references: CurrentAvatarReferenceProof
|
||||
|
||||
|
||||
async def validate_runtime_upload_database_state(
|
||||
pool: Any,
|
||||
) -> RuntimeUploadDatabaseProof:
|
||||
"""Bind readiness to one repeatable-read snapshot from this API's pool."""
|
||||
|
||||
if upload_manifest_proof is None:
|
||||
raise RuntimeError("public upload manifest proof is unavailable")
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.transaction(isolation="repeatable_read", readonly=True):
|
||||
database_target = await connected_database_target_sha256(connection)
|
||||
if database_target != settings.public_runtime_db_target_sha256:
|
||||
raise RuntimeError("connected public runtime database target drift")
|
||||
await connection.execute(
|
||||
"SELECT set_config('app.ai_context', '', true), "
|
||||
"set_config('app.current_role', 'admin', true)"
|
||||
)
|
||||
rows = await connection.fetch(
|
||||
"""
|
||||
SELECT avatar_url
|
||||
FROM app.app_user
|
||||
WHERE avatar_url IS NOT NULL
|
||||
AND btrim(avatar_url) LIKE '/uploads/%'
|
||||
ORDER BY avatar_url
|
||||
"""
|
||||
)
|
||||
current_references = validate_current_avatar_references(
|
||||
upload_root=upload_root,
|
||||
avatar_urls=[str(row["avatar_url"]) for row in rows],
|
||||
initial_manifest=upload_manifest_proof,
|
||||
expected_database_target_sha256=database_target,
|
||||
)
|
||||
return RuntimeUploadDatabaseProof(
|
||||
database_target_sha256=database_target,
|
||||
current_references=current_references,
|
||||
)
|
||||
|
||||
|
||||
def assert_public_avatar_url_exists(value: str) -> None:
|
||||
if not value.startswith("/uploads/"):
|
||||
return
|
||||
relative_path = public_avatar_url_to_relative_path(value)
|
||||
path_sha256 = public_avatar_relative_path_sha256(relative_path)
|
||||
preserved_decode_by_path = dict(
|
||||
upload_manifest_proof.preserved_path_decode_records
|
||||
if upload_manifest_proof is not None
|
||||
else ()
|
||||
)
|
||||
decode_valid = preserved_decode_by_path.get(path_sha256)
|
||||
if decode_valid is False:
|
||||
raise ValueError("public avatar URL object is forensic-only")
|
||||
candidate = upload_root / Path(*relative_path.split("/"))
|
||||
if decode_valid is True:
|
||||
assert_regular_file_without_reparse(
|
||||
candidate,
|
||||
"public avatar URL object",
|
||||
)
|
||||
return
|
||||
if not is_runtime_generated_public_avatar_relative_path(relative_path):
|
||||
raise ValueError("public avatar URL object is not authorized")
|
||||
read_decodable_public_avatar_object(
|
||||
relative_path=relative_path,
|
||||
path=candidate,
|
||||
label="public avatar URL object",
|
||||
)
|
||||
|
||||
|
||||
class FlatPublicAvatarStaticFiles(StaticFiles):
|
||||
"""Serve only one validated public avatar image, never nested/hidden extras."""
|
||||
|
||||
async def get_response(self, path: str, scope):
|
||||
method = str(scope.get("method", "GET")).upper()
|
||||
if method not in {"GET", "HEAD"}:
|
||||
raise StarletteHTTPException(status_code=405)
|
||||
try:
|
||||
normalized = public_avatar_url_to_relative_path(
|
||||
f"/uploads/profile-avatars/{path}"
|
||||
)
|
||||
filename = normalized.split("/", 1)[1]
|
||||
path_sha256 = public_avatar_relative_path_sha256(normalized)
|
||||
preserved_decode_by_path = dict(
|
||||
upload_manifest_proof.preserved_path_decode_records
|
||||
if upload_manifest_proof is not None
|
||||
else ()
|
||||
)
|
||||
preserved_public_cache = {
|
||||
record[0]: (record[1], record[2], record[3], record[4])
|
||||
for record in (
|
||||
upload_manifest_proof.preserved_records
|
||||
if upload_manifest_proof is not None
|
||||
else ()
|
||||
)
|
||||
}
|
||||
decode_valid = preserved_decode_by_path.get(path_sha256)
|
||||
if decode_valid is False:
|
||||
raise ValueError("public avatar object is forensic-only")
|
||||
preserved = preserved_public_cache.get(path_sha256)
|
||||
if preserved is not None:
|
||||
content_sha256, size_bytes, media_type, content = preserved
|
||||
body = content if method == "GET" else b""
|
||||
return Response(
|
||||
content=body,
|
||||
media_type=media_type,
|
||||
headers={
|
||||
"Content-Length": str(size_bytes),
|
||||
"ETag": f'"{content_sha256}"',
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
)
|
||||
if decode_valid is True:
|
||||
raise ValueError("validated public avatar cache is unavailable")
|
||||
if not is_runtime_generated_public_avatar_relative_path(normalized):
|
||||
raise ValueError("public avatar object is not authorized")
|
||||
candidate = Path(self.directory) / filename
|
||||
runtime_object = read_decodable_public_avatar_object(
|
||||
relative_path=normalized,
|
||||
path=candidate,
|
||||
label="public avatar static object",
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise StarletteHTTPException(status_code=404) from exc
|
||||
return Response(
|
||||
content=runtime_object.content if method == "GET" else b"",
|
||||
media_type=runtime_object.media_type,
|
||||
headers={
|
||||
"Content-Length": str(runtime_object.size_bytes),
|
||||
"ETag": f'"{runtime_object.content_sha256}"',
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
)
|
||||
1059
apps/api/app/upload_storage.py
Normal file
1059
apps/api/app/upload_storage.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ asyncpg==0.31.0
|
|||
pydantic==2.9.2
|
||||
pydantic-settings==2.12.0
|
||||
python-multipart==0.0.18
|
||||
Pillow==12.2.0
|
||||
httpx==0.28.1
|
||||
websockets==14.2
|
||||
sse-starlette==3.0.3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue