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:
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user