From e04e88e371b1e15bf768e058849bc9d0b7b3dae0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 08:56:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EA=B0=80=EC=9D=B4=EB=93=9C):=20538-4=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=20=EB=8D=94=EB=B8=94=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EA=B8=B0=20=E2=80=94=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EA=B4=80=EB=A6=AC=EC=9E=90=EB=A7=8C=20=C2=B7=20Web?= =?UTF-8?q?P=20=C2=B7=20=ED=8F=AD=201800=20=C2=B7=20=5Fmanual.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그림(또는 빈 그림 자리) 더블클릭 → 숨은 파일 고르기(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 --- common_util/common_util_help_router.py | 60 +++++++++++++++++++++- resources/tester/test_538_help_guide.py | 68 +++++++++++++++++++++++++ ui_template/ui_template_help_edit.ts | 37 ++++++++++++++ 3 files changed, 164 insertions(+), 1 deletion(-) diff --git a/common_util/common_util_help_router.py b/common_util/common_util_help_router.py index a08581ff3..93e7a2239 100644 --- a/common_util/common_util_help_router.py +++ b/common_util/common_util_help_router.py @@ -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} diff --git a/resources/tester/test_538_help_guide.py b/resources/tester/test_538_help_guide.py index f56daf093..cb0db7e2c 100644 --- a/resources/tester/test_538_help_guide.py +++ b/resources/tester/test_538_help_guide.py @@ -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() diff --git a/ui_template/ui_template_help_edit.ts b/ui_template/ui_template_help_edit.ts index fcc9a6f1e..8746c3515 100644 --- a/ui_template/ui_template_help_edit.ts +++ b/ui_template/ui_template_help_edit.ts @@ -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(".ui-help__item"); if (!section || open || target.closest("textarea, .ui-help__mine")) return; + const figure = target.closest("figure[data-img]"); + if (figure && !target.closest("figcaption")) { + pickFor = { section, name: figure.dataset.img ?? "" }; + return void picker.click(); + } const block = target.closest("[data-src]"); if (!block || !section.contains(block)) return; ev.preventDefault();