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
+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)