- 그림(또는 빈 그림 자리) 더블클릭 → 숨은 파일 고르기(png · jpg · webp) → 새 판으로 그 항목만 다시 그림
· 그림 한 번 누름 = 아무 일 없음 · 그림 아래 설명 더블클릭 = 그림 줄 글 고침
- POST /api/help/{page}/img (multipart · name = 그 그림 이름) — require_system_admin(403) · 확장자 ·
5 MB · Pillow 로 진짜 그림 확인 · 폭 1800 px 넘으면 줄임 · WebP 로 그 이름에 원자 쓰기 ·
_manual.json(손으로 바꾼 그림 이름 — 자동 캡처가 안 덮음)에 더함
- 시험: WebP · 줄임 · manual · 확장자 · 크기 · 그림 아님 · 경로 탈출 · 403
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
8.6 KiB
Python
215 lines
8.6 KiB
Python
"""[?] 사용자 가이드(54-2 · 538) — `A05_EduDetail/guide/<페이지>/*.md` 를 부를 때마다 읽어 줌.
|
|
|
|
사용자가 메모장으로 파일을 고치면 화면 고침 없이 다음에 열 때 바뀐 글이 보임.
|
|
파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름).
|
|
`_` 로 시작하는 파일 · 그림은 항목 아님.
|
|
판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목.
|
|
그림 = 페이지 폴더의 `이름.webp|png|jpg` · 판 = 고친 시각 · 내려주기는 로그인만 · `no-cache`.
|
|
바로 고침(538-3) = 시스템 관리자만 · 판이 같을 때만 그 줄 범위를 갈아 원자 쓰기 · 다르면 409.
|
|
그림 바꾸기(538-4) = 시스템 관리자만 · png · jpg · webp · 5 MB · Pillow 확인 · 폭 1800 px 로 줄임 →
|
|
WebP 로 그 이름에 덮음 · `_manual.json`(손으로 바꾼 그림 이름 — 자동 캡처가 안 덮음)에 더함.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import codecs
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import re
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
from fastapi.concurrency import run_in_threadpool
|
|
from fastapi.responses import FileResponse
|
|
from PIL import Image, ImageOps
|
|
from pydantic import BaseModel, Field
|
|
|
|
from common_util.common_util_atomic import atomic_write_bytes
|
|
from common_util.common_util_auth import require_system_admin, verify_session
|
|
|
|
router = APIRouter(prefix="/api/help", tags=["help"])
|
|
|
|
HELP_ROOT = Path(__file__).resolve().parent.parent / "A05_EduDetail" / "guide"
|
|
PAGE_RE = re.compile(r"^[BDE]\d{2}$")
|
|
IMG_RE = re.compile(r"^[A-Za-z0-9_-]+\.(webp|png|jpg)$")
|
|
|
|
|
|
def file_version(blob: bytes) -> str:
|
|
return hashlib.sha256(blob).hexdigest()[:16]
|
|
|
|
|
|
def image_version(path: Path) -> str:
|
|
return format(path.stat().st_mtime_ns, "x")
|
|
|
|
|
|
def page_folder(page: str, root: Path = HELP_ROOT) -> Path:
|
|
if not PAGE_RE.match(page):
|
|
raise HTTPException(status_code=404, detail="없는 페이지")
|
|
return root / page
|
|
|
|
|
|
def image_path(page: str, name: str, root: Path = HELP_ROOT) -> Path:
|
|
if not IMG_RE.match(name):
|
|
raise HTTPException(status_code=404, detail="없는 그림")
|
|
return page_folder(page, root) / name
|
|
|
|
|
|
def read_images(folder: Path) -> dict[str, str]:
|
|
return {p.name: image_version(p) for p in sorted(folder.iterdir()) if IMG_RE.match(p.name)}
|
|
|
|
|
|
def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]:
|
|
folder = page_folder(page, root)
|
|
items = []
|
|
for path in sorted(folder.glob("*.md")) if folder.is_dir() else []:
|
|
if path.name.startswith("_"):
|
|
continue
|
|
blob = path.read_bytes()
|
|
text = blob.decode("utf-8-sig")
|
|
head = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), "")
|
|
items.append(
|
|
{
|
|
"key": path.stem,
|
|
"title": head or path.stem,
|
|
"text": text,
|
|
"version": file_version(blob),
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def read_guide(root: Path = HELP_ROOT) -> list[dict[str, Any]]:
|
|
"""모든 페이지 — 폴더 이름 순 · 글 없는 폴더는 뺌."""
|
|
pages = []
|
|
for folder in sorted(p for p in root.iterdir() if p.is_dir() and PAGE_RE.match(p.name)):
|
|
items = read_help(folder.name, root)
|
|
if not items:
|
|
continue
|
|
manual = next((i for i in items if i["key"] == "00_설명서"), None)
|
|
pages.append(
|
|
{
|
|
"page": folder.name,
|
|
"title": manual["title"] if manual else folder.name,
|
|
"items": items,
|
|
"images": read_images(folder),
|
|
}
|
|
)
|
|
return pages
|
|
|
|
|
|
@router.get("")
|
|
async def get_guide(session: dict[str, Any] = Depends(verify_session)) -> dict[str, Any]:
|
|
return {"pages": read_guide(HELP_ROOT), "admin": session.get("role") == "SYSTEM_ADMIN"}
|
|
|
|
|
|
MEDIA = {b"RIFF": "image/webp", b"\x89PNG": "image/png"}
|
|
|
|
|
|
@router.get("/{page}/img/{name}")
|
|
def get_image(page: str, name: str) -> FileResponse:
|
|
path = image_path(page, name, HELP_ROOT)
|
|
if not path.is_file():
|
|
raise HTTPException(status_code=404, detail="없는 그림")
|
|
with path.open("rb") as f: # 올린 그림은 이름과 달리 WebP — 머리 글자로 가름
|
|
media = MEDIA.get(f.read(4), "image/jpeg")
|
|
return FileResponse(path, media_type=media, headers={"Cache-Control": "no-cache"})
|
|
|
|
|
|
class HelpEdit(BaseModel):
|
|
version: str = Field(max_length=64)
|
|
start: int = Field(ge=0)
|
|
end: int = Field(ge=0)
|
|
text: str = Field(max_length=200_000)
|
|
|
|
|
|
# ponytail: 서버 한 벌 안 잠금 — 판 확인 ~ 쓰기 사이 경합만 막음 · 서버 여러 벌이면 파일 잠금
|
|
_write_lock = threading.Lock()
|
|
|
|
|
|
def item_path(page: str, key: str, root: Path = HELP_ROOT) -> Path:
|
|
folder = page_folder(page, root)
|
|
bad = not key or key.startswith((".", "_")) or any(c in key for c in "/\\")
|
|
path = folder / f"{key}.md"
|
|
if bad or not path.is_file():
|
|
raise HTTPException(status_code=404, detail="없는 항목")
|
|
return path
|
|
|
|
|
|
def replace_lines(page: str, key: str, edit: HelpEdit, root: Path = HELP_ROOT) -> dict[str, str]:
|
|
"""판이 같을 때만 start~end 줄(0부터 · 끝 포함)을 글로 갈아 끼움 — 빈 글 = 그 줄 지움."""
|
|
path = item_path(page, key, root)
|
|
with _write_lock:
|
|
blob = path.read_bytes()
|
|
if file_version(blob) != edit.version:
|
|
raise HTTPException(status_code=409, detail="다른 관리자가 먼저 고침")
|
|
text = blob.decode("utf-8-sig")
|
|
newline = "\r\n" if "\r\n" in text else "\n"
|
|
lines = text.split(newline)
|
|
if edit.start > edit.end or edit.end >= len(lines):
|
|
raise HTTPException(status_code=400, detail="줄 범위 틀림")
|
|
new = edit.text.replace("\r\n", "\n").split("\n") if edit.text else []
|
|
lines[edit.start : edit.end + 1] = new
|
|
out = newline.join(lines).encode("utf-8")
|
|
atomic_write_bytes(path, codecs.BOM_UTF8 + out if blob.startswith(codecs.BOM_UTF8) else out)
|
|
return next(item for item in read_help(page, root) if item["key"] == key)
|
|
|
|
|
|
@router.put("/{page}/{key}", dependencies=[Depends(require_system_admin)])
|
|
def put_item(page: str, key: str, edit: HelpEdit) -> dict[str, dict[str, str]]:
|
|
return {"item": replace_lines(page, key, edit, HELP_ROOT)}
|
|
|
|
|
|
MAX_IMG_BYTES = 5 * 1024 * 1024
|
|
MAX_IMG_WIDTH = 1800
|
|
UPLOAD_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
|
|
|
|
|
|
def to_webp(blob: bytes) -> bytes:
|
|
if not blob or len(blob) > MAX_IMG_BYTES:
|
|
raise HTTPException(status_code=400, detail="그림은 5 MB 이하")
|
|
try:
|
|
with Image.open(io.BytesIO(blob)) as raw:
|
|
if raw.format not in {"PNG", "JPEG", "MPO", "WEBP"}: # MPO = 사진기 JPEG
|
|
raise HTTPException(status_code=400, detail="png · jpg · webp 그림만")
|
|
img = ImageOps.exif_transpose(raw)
|
|
if img.width > MAX_IMG_WIDTH:
|
|
img = img.resize((MAX_IMG_WIDTH, round(img.height * MAX_IMG_WIDTH / img.width)))
|
|
if img.mode not in ("RGB", "RGBA"):
|
|
img = img.convert(
|
|
"RGBA" if "transparency" in img.info or "A" in img.mode else "RGB"
|
|
)
|
|
out = io.BytesIO()
|
|
img.save(out, "WEBP", quality=90)
|
|
except (OSError, ValueError, Image.DecompressionBombError) as error:
|
|
raise HTTPException(status_code=400, detail="그림 파일 아님") from error
|
|
return out.getvalue()
|
|
|
|
|
|
def save_image(page: str, name: str, blob: bytes, root: Path = HELP_ROOT) -> str:
|
|
path = image_path(page, name, root)
|
|
if not path.parent.is_dir():
|
|
raise HTTPException(status_code=404, detail="없는 페이지")
|
|
webp = to_webp(blob)
|
|
manual = path.parent / "_manual.json"
|
|
with _write_lock:
|
|
atomic_write_bytes(path, webp)
|
|
names = set(json.loads(manual.read_text(encoding="utf-8"))) if manual.is_file() else set()
|
|
names.add(name)
|
|
text = json.dumps(sorted(names), ensure_ascii=False, indent=2) + "\n"
|
|
atomic_write_bytes(manual, text.encode("utf-8"))
|
|
return image_version(path)
|
|
|
|
|
|
@router.post("/{page}/img", dependencies=[Depends(require_system_admin)])
|
|
async def post_image(
|
|
page: str, name: str = Form(...), file: UploadFile = File(...)
|
|
) -> dict[str, str]:
|
|
if Path(file.filename or "").suffix.lower() not in UPLOAD_SUFFIXES:
|
|
raise HTTPException(status_code=400, detail="png · jpg · webp 그림만")
|
|
blob = await file.read(MAX_IMG_BYTES + 1)
|
|
version = await run_in_threadpool(save_image, page, name, blob, HELP_ROOT)
|
|
return {"name": name, "version": version}
|