fix(editor): harden review API edges

This commit is contained in:
xin61
2026-07-07 13:59:33 +08:00
parent 193047b3dc
commit 0aa568636d
6 changed files with 77 additions and 24 deletions
+4 -1
View File
@@ -41,8 +41,9 @@ epub_review_sessions/
- text chapter: `kind: "text"``parts``row_count``touched_count``marked_count`
- image chapter: `kind: "image"``images``parts: []``row_count: 0`
- 统计字段同时保留总数和细分:`chapter_count``text_chapter_count``image_chapter_count``image_count``asset_count`
- part 标题默认使用 `part0000` 形式,真实标题可保留在 `source_title`
- 插图通过 `/api/asset/<entry>` 读取,必须继续使用 `extracted_path()` 防止路径穿越
- 插图通过 `/api/asset/<entry>` 读取,必须继续使用 `extracted_path()` 防止路径穿越;只把 raster 图片纳入 `/api/structure``/api/asset` 白名单,不能任意读取解包目录文件
章节来源优先级:
@@ -89,6 +90,7 @@ GPT 能力是可选增强,不应影响离线审校。
- API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`
- `/api/gpt/config` 不得返回 API Key。
- 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。
- 重翻请求不得临时覆盖 `base_url``model` 后继续复用已保存密钥;要更换端点或模型必须先保存配置,避免把密钥发往未确认端点。
单段重翻流程:
@@ -134,6 +136,7 @@ Prompt 硬规则:
- `/api/structure`
- `/api/gpt/config`
- 首个插图 `/api/asset/...` 返回 200 且浏览器可显示
- 非白名单或非 raster asset 不能被 `/api/asset/...` 读取
- 目录按钮、检索按钮、隐藏侧栏、点击段落打开右侧面板
- 阅读段落下方没有编号和“快速编辑/快速标记”
- 未配置 API Key 时重翻返回清晰错误
+4 -2
View File
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器
当前版本:`0.4.0`
当前版本:`0.4.1`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -22,6 +22,8 @@
`0.4.0` 新增阅读型交互与 GPT 重翻:左侧目录/检索改为按需打开的独立侧栏;阅读模式去掉每段下方操作按钮,点击段落直接打开右侧精修与重翻面板,并在面板挤开正文后恢复原阅读位置;本地可配置 OpenAI 兼容 GPT API,对单段生成重翻候选,确认后再应用到编辑框。
`0.4.1` 修复后端/API 安全与结构细节:章节内插图与文字按 spine 顺序 flush,结构统计补充 text/image 细分字段;内部目录识别增加目录页/链接密度判定;结构和 `/api/asset` 仅允许已识别的 raster 图片并加 `nosniff`;重翻接口不再允许单次请求临时覆盖 Base URL 或模型后复用已保存密钥。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -129,5 +131,5 @@ epub_review_sessions\<epub-name>_<hash>\
- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。
- 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。
- GPT API Key 仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;服务器部署时请保护好审校目录,不要把没有鉴权的服务暴露到公网。
- GPT API Key 仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。服务器部署时请保护好审校目录,不要把没有鉴权的服务暴露到公网。
- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。
+67 -17
View File
@@ -26,7 +26,7 @@ from pathlib import Path
from typing import Any
import xml.etree.ElementTree as ET
from flask import Flask, jsonify, request, send_from_directory
from flask import Flask, jsonify, make_response, request, send_from_directory
try:
from version import VERSION_RULES, version
@@ -36,7 +36,7 @@ except ImportError:
"MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更",
}
version = "0.4.0"
version = "0.4.1"
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
@@ -51,6 +51,7 @@ GRAY_RE = re.compile(
)
EXTERNAL_URI_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//)", re.I)
IMAGE_EXTENSIONS = {".apng", ".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"}
RASTER_IMAGE_EXTENSIONS = {".apng", ".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
DEFAULT_REVIEW_ROOT = "epub_review_sessions"
STATE_VERSION = 1
@@ -754,6 +755,11 @@ def is_image_asset(entry_name: str) -> bool:
return suffix in IMAGE_EXTENSIONS
def is_raster_image_asset(entry_name: str) -> bool:
suffix = posixpath.splitext(entry_name.split("?", 1)[0])[1].lower()
return suffix in RASTER_IMAGE_EXTENSIONS
def asset_url(entry_name: str) -> str:
return f"/api/asset/{urllib.parse.quote(entry_name, safe='/')}"
@@ -770,7 +776,7 @@ def extract_images_from_html(session_root: Path, entry_name: str) -> list[dict[s
if not href or EXTERNAL_URI_RE.match(href):
continue
image_entry = normalize_href(entry_dir(entry_name), href)
if not image_entry or not is_image_asset(image_entry):
if not image_entry or not is_raster_image_asset(image_entry):
continue
images.append(
{
@@ -786,22 +792,38 @@ def extract_images_from_html(session_root: Path, entry_name: str) -> list[dict[s
return images
def collect_asset_paths_from_structure(structure: dict[str, Any]) -> set[str]:
paths: set[str] = set()
for chapter in structure.get("chapters", []):
for image in chapter.get("images", []):
asset_path = image.get("asset_path", "")
if asset_path:
paths.add(asset_path)
return paths
def nav_target_key(base_dir: str, href: str) -> tuple[str, str]:
path, fragment = normalize_href_with_fragment(base_dir, href)
return path, fragment
def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for entry_name in html_entries_in_reading_order(session_root):
ordered_files = html_entries_in_reading_order(session_root)
ordered_set = set(ordered_files)
for entry_name in ordered_files:
path = extracted_path(session_root, entry_name)
if not path.exists():
continue
text = path.read_text(encoding="utf-8", errors="replace")
if not re.search(r"<a\b[^>]*href=", text, flags=re.I):
continue
basename = posixpath.basename(entry_name).lower()
heading = extract_heading_from_html(text)
is_toc_like_name = basename in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"} or "toc" in basename
is_toc_like_title = bool(re.search(r"(目录|目次|contents?)", heading, flags=re.I))
matches = list(P_RE.finditer(text))
base_dir = entry_dir(entry_name)
candidate_items: list[dict[str, Any]] = []
i = 0
while i < len(matches):
source_inner = matches[i].group(2)
@@ -813,6 +835,9 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]:
if not target_file:
i += 1
continue
if target_file not in ordered_set:
i += 1
continue
source_title = compact_text(strip_tags(link_match.group(2))) or compact_text(strip_tags(source_inner))
title = source_title
if i + 1 < len(matches):
@@ -823,7 +848,7 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]:
title = next_title
i += 1
if title:
items.append(
candidate_items.append(
{
"title": title,
"href": target_file,
@@ -833,8 +858,11 @@ def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]:
}
)
i += 1
if items:
return items
if candidate_items:
internal_targets = sum(1 for item in candidate_items if item["href"] in ordered_set and item["href"] != entry_name)
link_density = internal_targets / max(1, len(matches))
if is_toc_like_name or is_toc_like_title or (len(candidate_items) >= 4 and link_density >= 0.35):
return candidate_items
return []
@@ -974,7 +1002,7 @@ def build_structure(session_root: Path) -> dict[str, Any]:
for entry_name in ordered_files:
heading = read_body_heading(session_root, entry_name)
if heading and entry_name not in start_titles:
if heading and entry_name not in start_titles and len(strip_tags(heading)) <= 80:
start_titles[entry_name] = heading
if start_titles:
@@ -1119,17 +1147,26 @@ def build_structure(session_root: Path) -> dict[str, Any]:
nonlocal chapter_serial
text_parts: list[dict[str, Any]] = []
text_title = title
def flush_text_parts() -> None:
nonlocal chapter_serial, text_parts, text_title
if not text_parts:
return
chapter_serial += 1
chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], text_parts))
text_parts = []
text_title = ""
for file_index, entry_name in enumerate(files):
if entry_name in image_by_file:
flush_text_parts()
images = image_by_file.get(entry_name, [])
for image_index, image in enumerate(images, start=1):
image_title = title if file_index == 0 and not text_parts else ""
chapters.append(make_image_chapter(entry_name, image, image_title, image_index, len(images)))
elif entry_name in rows_by_file:
text_parts.append(make_part(entry_name))
if text_parts:
chapter_serial += 1
chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], text_parts))
flush_text_parts()
for spec in chapter_specs:
node_files = [entry_name for entry_name in spec.get("files", []) if entry_name not in assigned_files]
@@ -1177,8 +1214,11 @@ def build_structure(session_root: Path) -> dict[str, Any]:
return {
"chapters": chapters,
"chapter_count": len(chapters),
"text_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") != "image"),
"image_chapter_count": sum(1 for chapter in chapters if chapter.get("kind") == "image"),
"part_count": sum(len(chapter["parts"]) for chapter in chapters),
"image_count": sum(len(chapter.get("images", [])) for chapter in chapters),
"asset_count": len(collect_asset_paths_from_structure({"chapters": chapters})),
"row_count": len(rows),
}
@@ -1563,13 +1603,21 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
if session_root is None:
return no_active_response()
entry_name = urllib.parse.unquote(entry_name)
structure = build_structure(session_root)
if entry_name not in collect_asset_paths_from_structure(structure):
return jsonify({"error": "asset not allowed"}), 404
if not is_raster_image_asset(entry_name):
return jsonify({"error": "asset type not allowed"}), 415
try:
path = extracted_path(session_root, entry_name)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if not path.exists() or not path.is_file():
return jsonify({"error": "asset not found"}), 404
return send_from_directory(path.parent, path.name)
response = make_response(send_from_directory(path.parent, path.name))
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Content-Security-Policy"] = "default-src 'none'; img-src 'self' data:; style-src 'none'; script-src 'none'; object-src 'none'"
return response
@app.route("/api/gpt/config", methods=["GET", "POST"])
def api_gpt_config():
@@ -1593,10 +1641,12 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
data = request.get_json(silent=True) or {}
try:
config = read_gpt_config(review_root)
if data.get("base_url"):
config["base_url"] = normalize_gpt_base_url(str(data.get("base_url")))
if data.get("model"):
config["model"] = str(data.get("model")).strip()
requested_base_url = str(data.get("base_url") or "").strip()
requested_model = str(data.get("model") or "").strip()
if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]:
return jsonify({"error": "重翻请求不能临时覆盖 Base URL。请先保存 API 设置,避免把已保存密钥发往未确认端点。"}), 400
if requested_model and requested_model != config["model"]:
return jsonify({"error": "重翻请求不能临时覆盖模型。请先保存 API 设置后再重翻。"}), 400
instruction = str(data.get("instruction") or "")
raw = call_openai_compatible_chat(config, build_retranslate_messages(row, instruction))
candidate_html = sanitize_cn_html(raw)
-2
View File
@@ -1199,8 +1199,6 @@ async function retranslateCurrentRow() {
method: "POST",
body: JSON.stringify({
instruction: dom.gptInstructionInput.value,
base_url: dom.gptBaseUrlInput.value,
model: dom.gptModelInput.value,
}),
});
gptCandidateHtml = data.candidate_html || "";
+1 -1
View File
@@ -9,7 +9,7 @@
<body>
<header class="topbar">
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.4.0</span></h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.4.1</span></h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
+1 -1
View File
@@ -1,4 +1,4 @@
version = "0.4.0"
version = "0.4.1"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",