資料夾功能修正

This commit is contained in:
2026-05-18 14:26:46 +08:00
parent 7acef614cc
commit f41e39846d
10 changed files with 1982 additions and 57 deletions

97
main.py
View File

@@ -62,18 +62,23 @@ def _save_project(slug: str, data: dict[str, Any]) -> None:
def _list_pages(slug: str) -> list[str]:
"""列出專案目錄下所有 .html 頁面."""
"""列出專案目錄下所有 .html 頁面(包含子資料夾)並回傳相對路徑."""
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return []
return sorted(p.name for p in proj_dir.glob("*.html"))
# 排除專案設定檔、寮本檔等非頁面檔
return sorted(
str(p.relative_to(proj_dir)).replace("\\", "/")
for p in proj_dir.rglob("*.html")
if p.name != "project.json"
)
def _project_summary(slug: str) -> dict[str, Any]:
data = _load_project(slug)
pages = _list_pages(slug)
proj_dir = _project_dir(slug)
html_files = list(proj_dir.glob("*.html"))
html_files = list(proj_dir.rglob("*.html"))
if html_files:
last_mod = max(f.stat().st_mtime for f in html_files)
last_mod_str = datetime.fromtimestamp(last_mod, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
@@ -88,10 +93,27 @@ def _project_summary(slug: str) -> dict[str, Any]:
}
def _sanitize_file_path(slug: str, filename: str) -> Path | None:
"""驗證並回傳安全的檔案路徑(防止路徑遍歷)."""
def _sanitize_file_path(slug: str, filename: str, folder: str = "") -> Path | None:
"""驗證並回傳安全的檔案路徑,支援子資料夾(防止路徑遍歷)."""
proj_dir = _project_dir(slug).resolve()
target = (proj_dir / Path(filename).name).resolve()
safe_filename = Path(filename).name # 只取最後的檔名,防止路徑注入
if folder:
safe_folder = str(Path(folder)).lstrip("/\\").replace("..", "")
target = (proj_dir / safe_folder / safe_filename).resolve()
else:
target = (proj_dir / safe_filename).resolve()
if not str(target).startswith(str(proj_dir)):
return None
if target.suffix.lower() != ".html":
return None
return target
def _sanitize_rel_path(slug: str, rel_path: str) -> Path | None:
"""驗證並回傳安全的相對路徑(支援子資料夾)."""
proj_dir = _project_dir(slug).resolve()
clean = rel_path.lstrip("/\\").replace("..", "")
target = (proj_dir / clean).resolve()
if not str(target).startswith(str(proj_dir)):
return None
if target.suffix.lower() != ".html":
@@ -128,18 +150,21 @@ def editor(slug: str) -> str:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
abort(404)
pages = _list_pages(slug)
pages = _list_pages(slug) # 現在回傳相對路徑列表,如 'index.html', 'sub/about.html'
project = _load_project(slug)
pages_obj: dict[str, Any] = {}
for page in pages:
name = page.replace(".html", "")
for rel_path in pages:
name = rel_path.replace(".html", "")
stem = Path(rel_path).stem.replace("-", " ").title()
folder_part = name.rsplit("/", 1)[0] if "/" in name else ""
pages_obj[name] = {
"name": name,
"title": name.replace("-", " ").title(),
"filename": page,
"file": page,
"url": f"/sites/{slug}/{page}",
"title": stem,
"folder": folder_part,
"filename": rel_path,
"file": rel_path,
"url": f"/sites/{slug}/{rel_path}",
}
if not pages_obj:
@@ -279,14 +304,16 @@ def api_save() -> tuple[Response, int] | Response:
if action == "rename":
file = str(body.get("file", "")).strip()
newfile = str(body.get("newfile", "")).strip()
new_title = str(body.get("title", "")).strip()
new_folder = str(body.get("folder", "")).strip()
duplicate_str = str(body.get("duplicate", "")).strip().lower()
is_duplicate = (duplicate_str == "true")
if not file or not newfile:
return jsonify({"error": "缺少參數 file 或 newfile"}), 400
old_path = _sanitize_file_path(slug, file)
new_path = _sanitize_file_path(slug, newfile)
old_path = _sanitize_rel_path(slug, file)
new_path = _sanitize_file_path(slug, newfile, new_folder)
if old_path is None or new_path is None:
return jsonify({"error": "不合法的頁面名稱"}), 400
@@ -297,6 +324,9 @@ def api_save() -> tuple[Response, int] | Response:
if new_path.exists() and old_path != new_path:
return jsonify({"error": "目標頁面已存在"}), 409
# 建立子資料夾(若需要)
new_path.parent.mkdir(parents=True, exist_ok=True)
if is_duplicate:
shutil.copy(old_path, new_path)
msg = "頁面複製成功"
@@ -304,12 +334,19 @@ def api_save() -> tuple[Response, int] | Response:
old_path.rename(new_path)
msg = "頁面重新命名成功"
# 計算相對於專案目錄的路徑,用於 URL
proj_dir = _project_dir(slug)
rel_path = new_path.relative_to(proj_dir)
page_name = str(rel_path).replace("\\", "/").replace(".html", "")
return jsonify({
"success": True,
"ok": True,
"message": msg,
"newfile": new_path.name,
"url": f"/sites/{slug}/{new_path.name}"
"newfile": str(rel_path).replace("\\", "/"),
"title": new_title or new_path.stem.replace("-", " ").title(),
"name": page_name,
"url": f"/sites/{slug}/{str(rel_path).replace(chr(92), '/')}"
})
# ── 處理刪除頁面 ──
@@ -339,19 +376,23 @@ def api_save() -> tuple[Response, int] | Response:
start_template_url = str(body.get("startTemplateUrl", "")).strip()
if start_template_url:
title = str(body.get("title", "")).strip() or "New Page"
# 取得安全的檔名 (只取檔名部分,例如 'about.html')
folder = str(body.get("folder", "")).strip()
# 取得安全的檔名
filename = Path(str(body.get("file", "untitled.html")).strip()).name
if not filename.endswith(".html"):
filename += ".html"
safe_path = _sanitize_file_path(slug, filename)
safe_path = _sanitize_file_path(slug, filename, folder)
if safe_path is None:
return jsonify({"error": "不合法的頁面名稱"}), 400
if safe_path.exists():
return jsonify({"error": "頁面已存在"}), 409
# 解析並複製樣板 (定位在 static/Vvvebjs 目錄下)
# 建立子資料夾(若需要)
safe_path.parent.mkdir(parents=True, exist_ok=True)
# 解析並複製樣板
template_source = BASE_DIR / "static" / "Vvvebjs" / start_template_url
if template_source.exists() and template_source.is_file():
shutil.copy(template_source, safe_path)
@@ -359,28 +400,32 @@ def api_save() -> tuple[Response, int] | Response:
_copy_blank_template(safe_path)
# 回傳 VvvebJS FileManager 所期待的 JSON 格式
page_name = safe_path.stem
proj_dir = _project_dir(slug)
rel_path = safe_path.relative_to(proj_dir)
page_name = str(rel_path).replace("\\", "/").replace(".html", "")
return jsonify({
"ok": True,
"name": page_name,
"title": title,
"file": safe_path.name,
"url": f"/sites/{slug}/{safe_path.name}"
"file": str(rel_path).replace("\\", "/"),
"url": f"/sites/{slug}/{str(rel_path).replace(chr(92), '/')}"
})
# 2. 一般儲存頁面請求
# 2. 一般儲存頁面請求(支援子資料夾路徑)
filename = str(body.get("file", "")).strip()
html: str = str(body.get("html", "")).strip()
if not filename or not html:
return jsonify({"error": "缺少必要參數 file / html"}), 400
safe_path = _sanitize_file_path(slug, filename)
safe_path = _sanitize_rel_path(slug, filename)
if safe_path is None:
return jsonify({"error": "不合法的檔案路徑"}), 400
safe_path.parent.mkdir(parents=True, exist_ok=True)
safe_path.write_text(html, encoding="utf-8")
return jsonify({"ok": True, "saved": safe_path.name})
return jsonify({"ok": True, "saved": str(safe_path.relative_to(_project_dir(slug))).replace("\\", "/")})