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