From 73bdb9bc5b2b16f26861226dc0d548d1c96e6430 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 08:53:34 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(=EA=B0=80=EC=9D=B4=EB=93=9C):=20538-3?= =?UTF-8?q?=20=EA=B8=80=20=EB=8D=94=EB=B8=94=ED=81=B4=EB=A6=AD=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=B9=A8=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=20?= =?UTF-8?q?=ED=8C=90=20409=20=C2=B7=20=EC=9B=90=EC=9E=90=20=EC=93=B0?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 원문 줄 textarea(높이 자동) · 바깥 누름 · Ctrl+Enter 저장(안 바뀌면 요청 없음) · Esc 취소 · 한글 조합 중 Enter · Esc 무시 · 고침 열린 채 모달 닫으면 저장 · 저장 뒤 그 항목만 다시 그림 - PUT /api/help/{page}/{key} {version, start, end, text} — require_system_admin(403) · 판 다르면 409 · 그 줄만 갈아 임시 파일 → os.replace · 줄바꿈(CRLF) · BOM 그대로 · 409 면 다시 불러오고 내 글은 절 머리에 보여 둠 - 모달 Esc: 몸이 막은 Esc · 한글 조합 중 Esc 는 안 닫음 - 시험: 줄 바꿈 · 빈 글 지움 · CRLF/BOM · 409 · 403 · 나쁜 범위/항목 · 원자 쓰기 실패 때 원본 그대로 Co-Authored-By: Claude Opus 5.5 --- common_util/common_util_help_router.py | 51 ++++++++- resources/tester/test_538_help_guide.py | 75 ++++++++++++- ui_template/ui_template_help.css | 27 +++++ ui_template/ui_template_help.ts | 5 + ui_template/ui_template_help_edit.ts | 136 ++++++++++++++++++++++++ ui_template/ui_template_modal.ts | 6 +- 6 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 ui_template/ui_template_help_edit.ts diff --git a/common_util/common_util_help_router.py b/common_util/common_util_help_router.py index 597efb0ae..a08581ff3 100644 --- a/common_util/common_util_help_router.py +++ b/common_util/common_util_help_router.py @@ -5,19 +5,24 @@ `_` 로 시작하는 파일 · 그림은 항목 아님. 판(version) = 파일 sha256 앞 16자 · 페이지 제목 = `00_설명서` 의 제목. 그림 = 페이지 폴더의 `이름.webp|png|jpg` · 판 = 고친 시각 · 내려주기는 로그인만 · `no-cache`. +바로 고침(538-3) = 시스템 관리자만 · 판이 같을 때만 그 줄 범위를 갈아 원자 쓰기 · 다르면 409. """ from __future__ import annotations +import codecs import hashlib import re +import threading from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import FileResponse +from pydantic import BaseModel, Field -from common_util.common_util_auth import verify_session +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"]) @@ -105,3 +110,47 @@ def get_image(page: str, name: str) -> FileResponse: 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)} diff --git a/resources/tester/test_538_help_guide.py b/resources/tester/test_538_help_guide.py index 8fbe49616..f56daf093 100644 --- a/resources/tester/test_538_help_guide.py +++ b/resources/tester/test_538_help_guide.py @@ -88,7 +88,7 @@ def test_html_은_먼저_막음(html) -> None: def root(monkeypatch): path = Path(tempfile.mkdtemp()) (path / "B05").mkdir() - (path / "B05" / "00_설명서.md").write_text("# 종단\n\n첫 문단\n", encoding="utf-8") + (path / "B05" / "00_설명서.md").write_bytes("# 종단\n\n첫 문단\n".encode()) monkeypatch.setattr(help_router, "HELP_ROOT", path) yield path shutil.rmtree(path, ignore_errors=True) @@ -130,3 +130,76 @@ def test_그림_내려주기_no_cache(root) -> None: def test_그림_404(root, url) -> None: (root / "B05" / "full.webp").write_bytes(b"RIFF0000WEBP") assert client().get(url).status_code == 404 + + +# ── 538-3 글 바로 고침 ──────────────────────────────────────────────────────── + + +def put(c: TestClient, version: str, start: int, end: int, text: str, key: str = "00_설명서"): + body = {"version": version, "start": start, "end": end, "text": text} + return c.put(f"/api/help/B05/{key}", json=body) + + +def version_of(root: Path) -> str: + return help_router.file_version((root / "B05" / "00_설명서.md").read_bytes()) + + +def test_그_줄만_갈고_새_판(root) -> None: + old = version_of(root) + res = put(client(), old, 2, 2, "고친 문단\n둘째 줄") + assert res.status_code == 200 + item = res.json()["item"] + text = (root / "B05" / "00_설명서.md").read_bytes().decode() + assert text == "# 종단\n\n고친 문단\n둘째 줄\n" == item["text"] + assert item["version"] == version_of(root) != old + assert put(client(), item["version"], 2, 3, "").status_code == 200 # 빈 글 = 그 줄 지움 + assert (root / "B05" / "00_설명서.md").read_bytes().decode() == "# 종단\n\n" + + +def test_crlf_bom_그대로(root) -> None: + path = root / "B05" / "00_설명서.md" + path.write_bytes(b"\xef\xbb\xbf# A\r\n\r\nB\r\n") + assert put(client(), version_of(root), 2, 2, "C").status_code == 200 + assert path.read_bytes() == b"\xef\xbb\xbf# A\r\n\r\nC\r\n" + + +def test_판_다르면_409_파일_그대로(root) -> None: + before = (root / "B05" / "00_설명서.md").read_bytes() + assert put(client(), "0" * 16, 2, 2, "남의 글").status_code == 409 + assert (root / "B05" / "00_설명서.md").read_bytes() == before + + +def test_시스템_관리자만_403(root) -> None: + before = (root / "B05" / "00_설명서.md").read_bytes() + for role in ["USER", "COMPANY_ADMIN"]: + assert put(client(role), version_of(root), 2, 2, "x").status_code == 403 + assert (root / "B05" / "00_설명서.md").read_bytes() == before + + +@pytest.mark.parametrize( + ("start", "end", "key", "code"), + [ + (3, 2, "00_설명서", 400), + (0, 9, "00_설명서", 400), + (0, 0, "없음", 404), + (0, 0, "_manual", 404), + (0, 0, "..%2F..%2FCLAUDE", 404), + ], +) +def test_나쁜_범위_항목(root, start, end, key, code) -> None: + assert put(client(), version_of(root), start, end, "x", key).status_code == code + + +def test_원자_쓰기_실패해도_원본_그대로(root, monkeypatch) -> None: + before = (root / "B05" / "00_설명서.md").read_bytes() + + def boom(*_args): + raise OSError("쓰기 실패") + + monkeypatch.setattr("common_util.common_util_atomic.os.replace", boom) + with pytest.raises(OSError): + put(client(), version_of(root), 2, 2, "반쯤") + assert (root / "B05" / "00_설명서.md").read_bytes() == before + assert sorted(p.name for p in (root / "B05").iterdir()) == [ + "00_설명서.md" + ] # 임시 파일 남지 않음 diff --git a/ui_template/ui_template_help.css b/ui_template/ui_template_help.css index f75f96e52..b4c1df41c 100644 --- a/ui_template/ui_template_help.css +++ b/ui_template/ui_template_help.css @@ -185,3 +185,30 @@ .ui-help__text ol { padding-left: 1.4em; } + +/* 바로 고침(538-3) — 시스템 관리자만 · 덩어리 둘레 점선 · 원문 글 상자 */ +.ui-help__text.is-editable [data-src]:hover { + outline: 1px dashed var(--color-border); + outline-offset: 2px; +} + +.ui-help__edit { + display: block; + box-sizing: border-box; + width: 100%; + margin: 0 0 var(--spacing-8); + padding: var(--spacing-8); + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text-body); + font: inherit; + line-height: 1.6; + resize: vertical; +} + +.ui-help__mine { + margin: 0 0 var(--spacing-8); + color: var(--color-warning); + font-size: 0.9em; +} diff --git a/ui_template/ui_template_help.ts b/ui_template/ui_template_help.ts index cd00421b0..12e58697f 100644 --- a/ui_template/ui_template_help.ts +++ b/ui_template/ui_template_help.ts @@ -14,6 +14,7 @@ import "./ui_template_help.css"; import { API_BASE_URL, PLAN_STEP_ROUTES, ROUTES, STAB_STEP_ROUTES } from "@config/config_frontend"; import { createButton, el } from "./ui_template_elements"; +import { attachHelpEditing } from "./ui_template_help_edit"; import { openModal } from "./ui_template_modal"; export interface HelpItem { @@ -306,6 +307,10 @@ async function openHelp(): Promise { ev.preventDefault(); goTo(goto.dataset.helpGoto ?? ""); }); + if (guide.admin) + flush = attachHelpEditing(article, pages, (page, item) => + renderHelpItem(page, item, true), + ).flush; body.append(el("div", { className: "ui-help__grid", children: [toc, article] })); // 모달이 붙은 뒤 이동 — mount 는 붙기 전 requestAnimationFrame(() => goTo(start)); diff --git a/ui_template/ui_template_help_edit.ts b/ui_template/ui_template_help_edit.ts new file mode 100644 index 000000000..fcc9a6f1e --- /dev/null +++ b/ui_template/ui_template_help_edit.ts @@ -0,0 +1,136 @@ +/* ============================================================================= + * ui_template_help_edit.ts + * [?] 사용자 가이드 바로 고침(538-3) — 시스템 관리자만(서버도 시스템 관리자만 받음). + * 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 덩어리 원문 마크다운 textarea 로 바꿔 끼움. + * 바깥 누름 · Ctrl+Enter = 저장(안 바뀌었으면 요청 안 함) · Esc = 취소 · 한글 조합 중 Enter · Esc 무시 · + * 고침이 열린 채 모달을 닫으면 저장(`flush`). + * 저장 = `PUT /api/help/{page}/{key}` {version, start, end, text} → 그 항목만 다시 그림 · + * 409 = 「다른 관리자가 먼저 고침」 → 다시 불러옴 + 내 글은 그 절 머리에 보여 둠. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { el, showToast } from "./ui_template_elements"; +import type { HelpGuide, HelpItem, HelpPage } from "./ui_template_help"; + +const TEXT = { + conflict: "다른 관리자가 먼저 고침 — 다시 불러옴", + failed: "저장 못 함 — 다시 불러옴", + mine: "내가 쓴 글 — 저장 안 됨 · 복사해 다시 넣음", +}; + +type Render = (page: HelpPage, item: HelpItem) => string; + +export function attachHelpEditing( + article: HTMLElement, + pages: HelpPage[], + render: Render, +): { flush: () => Promise } { + let open: (() => Promise) | null = null; + let pending: Promise = Promise.resolve(); + article.classList.add("is-editable"); + + const find = (section: HTMLElement): { page: HelpPage; item: HelpItem } | null => { + const page = pages.find((p) => p.page === section.dataset.page); + const item = page?.items.find((i) => i.key === section.dataset.key); + return page && item ? { page, item } : null; + }; + + /** 서버의 지금 글로 그 항목 · 그림 판을 바꿔 둠 */ + const reload = async (page: HelpPage, item: HelpItem): Promise => { + const response = await fetch(`${API_BASE_URL}/help`, { + credentials: "include", + cache: "no-store", + }); + if (!response.ok) return; + const fresh = ((await response.json()) as HelpGuide).pages.find((p) => p.page === page.page); + const freshItem = fresh?.items.find((i) => i.key === item.key); + if (fresh) page.images = fresh.images; + if (freshItem) Object.assign(item, freshItem); + }; + + const keepMine = (section: HTMLElement, text: string): void => { + const area = el("textarea", { className: "ui-help__edit", attrs: { readonly: "" } }); + area.value = text; + section.prepend(el("div", { className: "ui-help__mine", children: [TEXT.mine, area] })); + }; + + const edit = (section: HTMLElement, block: HTMLElement): void => { + const found = find(section); + if (!found) return; + const { page, item } = found; + const [start, end] = (block.dataset.src ?? "").split("-").map(Number); + const before = item.text + .split(/\r?\n/) + .slice(start, end + 1) + .join("\n"); + const area = el("textarea", { className: "ui-help__edit" }); + area.value = before; + const fit = (): void => { + area.style.height = "auto"; + area.style.height = `${area.scrollHeight + 2}px`; + }; + let done = false; + const finish = async (keep: boolean): Promise => { + if (done) return; + done = true; + open = null; + const text = area.value; + if (!keep || text === before) return void area.replaceWith(block); + area.disabled = true; + const response = await fetch( + `${API_BASE_URL}/help/${page.page}/${encodeURIComponent(item.key)}`, + { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ version: item.version, start, end, text }), + }, + ).catch(() => null); + if (response?.ok) { + Object.assign(item, ((await response.json()) as { item: HelpItem }).item); + section.innerHTML = render(page, item); + return; + } + showToast(response?.status === 409 ? TEXT.conflict : TEXT.failed, "warning"); + await reload(page, item).catch(() => undefined); + section.innerHTML = render(page, item); + keepMine(section, text); + }; + area.addEventListener("input", fit); + area.addEventListener("keydown", (ev) => { + if (ev.isComposing || ev.keyCode === 229) return; + if (ev.key === "Escape") { + ev.preventDefault(); // 모달 Esc 닫기도 막음 + ev.stopPropagation(); + void finish(false); + } else if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) { + ev.preventDefault(); + pending = finish(true); + } + }); + area.addEventListener("blur", () => (pending = finish(true))); + open = () => (pending = finish(true)); + block.replaceWith(area); + fit(); + area.focus(); + }; + + 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 block = target.closest("[data-src]"); + if (!block || !section.contains(block)) return; + ev.preventDefault(); + window.getSelection()?.removeAllRanges(); + edit(section, block); + }); + + return { + flush: async () => { + if (open) await open(); + await pending; + return true; + }, + }; +} diff --git a/ui_template/ui_template_modal.ts b/ui_template/ui_template_modal.ts index a74de124d..77fb4fa25 100644 --- a/ui_template/ui_template_modal.ts +++ b/ui_template/ui_template_modal.ts @@ -55,7 +55,11 @@ export function openModal(opts: ModalOptions): { close: () => void } { backdrop.addEventListener("pointerdown", (ev) => (outside = ev.target === backdrop)); backdrop.addEventListener("pointerup", (ev) => (outside &&= ev.target === backdrop)); backdrop.addEventListener("click", (ev) => ev.target === backdrop && outside && void ask()); - backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && void ask()); + // 몸이 막은 Esc(글 고침 취소) · 한글 조합 중 Esc 는 안 닫음 + backdrop.addEventListener( + "keydown", + (ev) => ev.key === "Escape" && !ev.defaultPrevented && !ev.isComposing && void ask(), + ); opts.mount(body, close, actions); // 단추가 있을 때만 머리에 — [닫기] 는 머리 직속 그대로(B05 추천의 `.ui-modal__top > button` 갈아 끼우기) if (actions.childElementCount) closeButton.before(actions); From e04e88e371b1e15bf768e058849bc9d0b7b3dae0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 08:56:20 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(=EA=B0=80=EC=9D=B4=EB=93=9C):=20538-4?= =?UTF-8?q?=20=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(); From 32d9df0de7d5738e832807607e7a32bd2e07a4d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 08:59:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(A05=20=EA=B0=80=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98):=20=EC=9E=A5=EB=A9=B4=EB=A7=88=EB=8B=A4=20s?= =?UTF-8?q?essionStorage=20=EB=B9=84=EC=9B=80=20=C2=B7=20clip=20=C2=B7=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EB=8C=80=EC=83=81=20=EC=B0=8D=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=84=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=C2=B7=20before=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20select=20=C2=B7=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 장면 첫 적재 때 sessionStorage 비움(window.name 표시 · 장면 안 새로고침은 지킴) · localStorage 프로젝트 · 테마 그대로 - clip · callouts 대상 scrollIntoView 뒤 자리 잼 — 화면 밖 대상 번호 어긋남 막음 - 단계 열쇠 select(value · Playwright select_option) · scroll(선택자) · 머리 설명 갱신 - 시험 test_539_guide_capture 덧(select · scroll 통과 · select value 없음 · scroll 빈 글 거절) Co-Authored-By: Claude Opus 5.5 --- A05_EduDetail/A05_EduDetail_Guide_Capture.py | 36 ++++++++++++++++---- resources/tester/test_539_guide_capture.py | 11 +++++- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/A05_EduDetail/A05_EduDetail_Guide_Capture.py b/A05_EduDetail/A05_EduDetail_Guide_Capture.py index 3c475c30f..468b236d4 100644 --- a/A05_EduDetail/A05_EduDetail_Guide_Capture.py +++ b/A05_EduDetail/A05_EduDetail_Guide_Capture.py @@ -33,6 +33,8 @@ 단계 = 아래 열쇠 중 하나 + 덧붙임 {"click": ""} 누름 · {"click": …, "confirm": true} = 확인창만 여는 위험 단추(아래) {"fill": "", "value": "글"} 칸 채움 + {"select": "", "value": "값"} 고르기 칸(