資料夾功能修正
This commit is contained in:
97
main.py
97
main.py
@@ -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("\\", "/")})
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user