아바타 저장소 승격 계약을 완성

This commit is contained in:
Yun Chan 2026-08-29 23:58:33 +09:00
parent ac9b702688
commit ccdcfcd2f5
36 changed files with 14734 additions and 222 deletions

View file

@ -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(

View file

@ -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),

View file

@ -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}
),
}

View file

@ -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")

View file

@ -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,

File diff suppressed because it is too large Load diff

View 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",
},
)

File diff suppressed because it is too large Load diff

View file

@ -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