아바타 저장소 승격 계약을 완성
This commit is contained in:
parent
ac9b702688
commit
ccdcfcd2f5
36 changed files with 14734 additions and 222 deletions
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",
|
||||
},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue