feat(가이드): 538-4 그림 더블클릭 바꾸기 — 시스템 관리자만 · WebP · 폭 1800 · _manual.json

- 그림(또는 빈 그림 자리) 더블클릭 → 숨은 파일 고르기(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>
This commit is contained in:
2026-10-26 08:56:20 +09:00
co-authored by Claude Opus 5.5
parent 73bdb9bc5b
commit e04e88e371
3 changed files with 164 additions and 1 deletions
+59 -1
View File
@@ -6,19 +6,25 @@
판(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, HTTPException
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
@@ -154,3 +160,55 @@ def replace_lines(page: str, key: str, edit: HelpEdit, root: Path = HELP_ROOT) -
@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}
+68
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
import io
import json
import os
import shutil
@@ -203,3 +204,70 @@ def test_원자_쓰기_실패해도_원본_그대로(root, monkeypatch) -> None:
assert sorted(p.name for p in (root / "B05").iterdir()) == [
"00_설명서.md"
] # 임시 파일 남지 않음
# ── 538-4 그림 바꾸기 ─────────────────────────────────────────────────────────
def png(width: int = 40, height: int = 20) -> bytes:
from PIL import Image
out = io.BytesIO()
Image.new("RGB", (width, height), (200, 30, 30)).save(out, "PNG")
return out.getvalue()
def upload(c: TestClient, name: str, blob: bytes, filename: str = "고른.png", page: str = "B05"):
return c.post(
f"/api/help/{page}/img",
data={"name": name},
files={"file": (filename, blob, "application/octet-stream")},
)
def test_그림_webp_로_덮고_manual_에_적음(root) -> None:
from PIL import Image
(root / "B05" / "full.webp").write_bytes(b"old")
res = upload(client(), "full.webp", png(3000, 1500))
assert res.status_code == 200
body = res.json()
with Image.open(root / "B05" / "full.webp") as img:
assert (img.format, img.size) == ("WEBP", (1800, 900)) # 폭 1800 넘으면 줄임
assert body == {
"name": "full.webp",
"version": help_router.image_version(root / "B05" / "full.webp"),
}
assert upload(client(), "new_one.webp", png(), "a.jpg").status_code == 200 # 새 이름도 받음
manual = json.loads((root / "B05" / "_manual.json").read_text(encoding="utf-8"))
assert manual == ["full.webp", "new_one.webp"]
assert [i["key"] for i in client().get("/api/help").json()["pages"][0]["items"]] == [
"00_설명서"
]
@pytest.mark.parametrize(
("name", "blob", "filename", "code"),
[
("full.webp", png(), "고른.gif", 400), # 확장자
("full.webp", b"not an image at all", "고른.png", 400), # 그림 아님
("full.webp", b"", "고른.png", 400),
("../full.webp", png(), "고른.png", 404), # 경로 탈출
("full.gif", png(), "고른.png", 404),
("한글.webp", png(), "고른.png", 404),
],
)
def test_그림_거절(root, name, blob, filename, code) -> None:
assert upload(client(), name, blob, filename).status_code == code
assert sorted(p.name for p in (root / "B05").iterdir()) == ["00_설명서.md"]
def test_그림_5MB_넘으면_거절(root, monkeypatch) -> None:
monkeypatch.setattr(help_router, "MAX_IMG_BYTES", 1000)
assert upload(client(), "full.webp", png(400, 400) + b"\0" * 2000).status_code == 400
def test_그림_시스템_관리자만_403(root) -> None:
assert upload(client("USER"), "full.webp", png()).status_code == 403
assert upload(client(), "full.webp", png(), page="..%2FB05").status_code == 404
assert not (root / "B05" / "full.webp").exists()
+37
View File
@@ -6,6 +6,8 @@
* 고침이 열린 채 모달을 닫으면 저장(`flush`).
* 저장 = `PUT /api/help/{page}/{key}` {version, start, end, text} → 그 항목만 다시 그림 ·
* 409 = 「다른 관리자가 먼저 고침」 → 다시 불러옴 + 내 글은 그 절 머리에 보여 둠.
* 그림 바꾸기(538-4) = 그림(또는 빈 그림 자리) 더블클릭 → 파일 고르기 → `POST /api/help/{page}/img`
* (그 그림 이름에 WebP 로 덮음) → 새 판으로 그 항목만 다시 그림. 그림 한 번 누름 = 아무 일 없음.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -16,6 +18,7 @@ const TEXT = {
conflict: "다른 관리자가 먼저 고침 — 다시 불러옴",
failed: "저장 못 함 — 다시 불러옴",
mine: "내가 쓴 글 — 저장 안 됨 · 복사해 다시 넣음",
imgFailed: "그림 못 바꿈",
};
type Render = (page: HelpPage, item: HelpItem) => string;
@@ -115,10 +118,44 @@ export function attachHelpEditing(
area.focus();
};
// 그림 고르기 — 하나를 돌려 씀 · 고른 뒤 값 비움(같은 파일 다시 골라도 change)
const picker = el("input", {
attrs: { type: "file", accept: "image/png,image/jpeg,image/webp" },
});
let pickFor: { section: HTMLElement; name: string } | null = null;
picker.addEventListener("change", async () => {
const file = picker.files?.[0];
picker.value = "";
const found = pickFor && find(pickFor.section);
if (!file || !pickFor || !found) return;
const { section, name } = pickFor;
const form = new FormData();
form.append("name", name);
form.append("file", file);
const response = await fetch(`${API_BASE_URL}/help/${found.page.page}/img`, {
method: "POST",
credentials: "include",
body: form,
}).catch(() => null);
const result = (await response?.json().catch(() => null)) as {
version?: string;
detail?: string;
} | null;
if (!response?.ok || !result?.version)
return void showToast(result?.detail ?? TEXT.imgFailed, "error");
found.page.images = { ...found.page.images, [name]: result.version };
section.innerHTML = render(found.page, found.item);
});
article.addEventListener("dblclick", (ev) => {
const target = ev.target as HTMLElement;
const section = target.closest<HTMLElement>(".ui-help__item");
if (!section || open || target.closest("textarea, .ui-help__mine")) return;
const figure = target.closest<HTMLElement>("figure[data-img]");
if (figure && !target.closest("figcaption")) {
pickFor = { section, name: figure.dataset.img ?? "" };
return void picker.click();
}
const block = target.closest<HTMLElement>("[data-src]");
if (!block || !section.contains(block)) return;
ev.preventDefault();