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

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

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