"""VvvebJS 網頁管理器 — Flask 主程式.""" from __future__ import annotations import json import re import shutil from datetime import datetime, timezone from pathlib import Path from typing import Any from flask import Flask, Response, abort, jsonify, render_template, request, send_from_directory app = Flask(__name__) # ── 路徑常數 ──────────────────────────────────────────────────────────────── BASE_DIR = Path(__file__).parent WEBSITES_DIR = BASE_DIR / "websites" BLANK_TEMPLATE = BASE_DIR / "static" / "Vvvebjs" / "new-page-blank-template.html" # ── 輔助函式 ──────────────────────────────────────────────────────────────── def slugify(text: str) -> str: """將文字轉成 URL-safe slug.""" text = text.lower().strip() text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE) text = re.sub(r"[\s_-]+", "-", text) text = re.sub(r"^-+|-+$", "", text) return text or "untitled" def _now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") def _project_dir(slug: str) -> Path: return WEBSITES_DIR / slug def _project_json_path(slug: str) -> Path: return _project_dir(slug) / "project.json" def _load_project(slug: str) -> dict[str, Any]: """讀取專案設定,若不存在則回傳預設值.""" path = _project_json_path(slug) if path.exists(): data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8")) return data return { "name": slug, "slug": slug, "description": "", "created_at": _now_iso(), } def _save_project(slug: str, data: dict[str, Any]) -> None: path = _project_json_path(slug) path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") def _list_pages(slug: str) -> list[str]: """列出專案目錄下所有 .html 頁面(包含子資料夾)並回傳相對路徑.""" proj_dir = _project_dir(slug) if not proj_dir.exists(): return [] # 排除專案設定檔、寮本檔等非頁面檔 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.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") else: last_mod_str = str(data.get("created_at", _now_iso())) return { **data, "slug": slug, "page_count": len(pages), "pages": pages, "last_modified": last_mod_str, } def _sanitize_file_path(slug: str, filename: str, folder: str = "") -> Path | None: """驗證並回傳安全的檔案路徑,支援子資料夾(防止路徑遍歷).""" proj_dir = _project_dir(slug).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": return None return target def _copy_blank_template(dest: Path) -> None: """複製空白範本或建立最小 HTML.""" if BLANK_TEMPLATE.exists(): shutil.copy(BLANK_TEMPLATE, dest) else: dest.write_text( "My Page", encoding="utf-8", ) # ── 路由:一般頁面 ────────────────────────────────────────────────────────── @app.route("/") # type: ignore[untyped-decorator] def index() -> Response: from flask import redirect, url_for return redirect(url_for("dashboard")) @app.route("/dashboard") # type: ignore[untyped-decorator] def dashboard() -> str: return str(render_template("dashboard.html")) @app.route("/editor/") # type: ignore[untyped-decorator] def editor(slug: str) -> str: proj_dir = _project_dir(slug) if not proj_dir.exists(): abort(404) pages = _list_pages(slug) # 現在回傳相對路徑列表,如 'index.html', 'sub/about.html' project = _load_project(slug) pages_obj: dict[str, Any] = {} 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": stem, "folder": folder_part, "filename": rel_path, "file": rel_path, "url": f"/sites/{slug}/{rel_path}", } if not pages_obj: idx_path = proj_dir / "index.html" _copy_blank_template(idx_path) pages_obj["index"] = { "name": "index", "title": "Index", "filename": "index.html", "file": "index.html", "url": f"/sites/{slug}/index.html", } return str(render_template( "editor.html", slug=slug, project=project, pages_json=json.dumps(pages_obj, ensure_ascii=False), )) # ── 路由:API ─────────────────────────────────────────────────────────────── @app.route("/api/projects", methods=["GET"]) # type: ignore[untyped-decorator] def api_list_projects() -> Response: WEBSITES_DIR.mkdir(exist_ok=True) projects = [_project_summary(d.name) for d in sorted(WEBSITES_DIR.iterdir()) if d.is_dir()] return jsonify(projects) @app.route("/api/projects", methods=["POST"]) # type: ignore[untyped-decorator] def api_create_project() -> tuple[Response, int]: body: dict[str, Any] = request.get_json(force=True) or {} name: str = str(body.get("name", "")).strip() if not name: return jsonify({"error": "名稱不能為空"}), 400 slug = slugify(name) proj_dir = _project_dir(slug) base_slug = slug counter = 1 while proj_dir.exists(): slug = f"{base_slug}-{counter}" proj_dir = _project_dir(slug) counter += 1 proj_dir.mkdir(parents=True, exist_ok=True) data: dict[str, Any] = { "name": name, "slug": slug, "description": str(body.get("description", "")), "created_at": _now_iso(), } _save_project(slug, data) _copy_blank_template(proj_dir / "index.html") return jsonify(_project_summary(slug)), 201 @app.route("/api/projects/", methods=["GET"]) # type: ignore[untyped-decorator] def api_get_project(slug: str) -> tuple[Response, int] | Response: if not _project_dir(slug).exists(): return jsonify({"error": "專案不存在"}), 404 return jsonify(_project_summary(slug)) @app.route("/api/projects/", methods=["PUT"]) # type: ignore[untyped-decorator] def api_update_project(slug: str) -> tuple[Response, int] | Response: if not _project_dir(slug).exists(): return jsonify({"error": "專案不存在"}), 404 body: dict[str, Any] = request.get_json(force=True) or {} data = _load_project(slug) if "name" in body and str(body["name"]).strip(): data["name"] = str(body["name"]).strip() if "description" in body: data["description"] = str(body["description"]) data["updated_at"] = _now_iso() _save_project(slug, data) return jsonify(_project_summary(slug)) @app.route("/api/projects/", methods=["DELETE"]) # type: ignore[untyped-decorator] def api_delete_project(slug: str) -> tuple[Response, int] | Response: proj_dir = _project_dir(slug) if not proj_dir.exists(): return jsonify({"error": "專案不存在"}), 404 shutil.rmtree(proj_dir) return jsonify({"ok": True}) @app.route("/api/projects//pages", methods=["GET"]) # type: ignore[untyped-decorator] def api_list_pages(slug: str) -> tuple[Response, int] | Response: if not _project_dir(slug).exists(): return jsonify({"error": "專案不存在"}), 404 return jsonify(_list_pages(slug)) @app.route("/api/projects//pages", methods=["POST"]) # type: ignore[untyped-decorator] def api_create_page(slug: str) -> tuple[Response, int]: if not _project_dir(slug).exists(): return jsonify({"error": "專案不存在"}), 404 body: dict[str, Any] = request.get_json(force=True) or {} page_name: str = str(body.get("name", "")).strip() if not page_name: return jsonify({"error": "頁面名稱不能為空"}), 400 page_slug = slugify(page_name) page_path = _project_dir(slug) / f"{page_slug}.html" if page_path.exists(): return jsonify({"error": "頁面已存在"}), 409 _copy_blank_template(page_path) return jsonify({"page": f"{page_slug}.html"}), 201 @app.route("/api/save", methods=["POST"]) # type: ignore[untyped-decorator] def api_save() -> tuple[Response, int] | Response: """接受 vvvebjs 的儲存或新增頁面請求,寫入對應專案目錄.""" if request.is_json: raw: dict[str, Any] = request.get_json(force=True) or {} body: dict[str, Any] = raw else: form = dict(request.form) body = {k: (v[0] if isinstance(v, list) else v) for k, v in form.items()} slug: str = str(body.get("slug", "")).strip() if not slug: # Fallback to query parameter if not in body (e.g. for some raw requests) slug = request.args.get("slug", "").strip() if not slug: return jsonify({"error": "缺少專案識別碼 (slug)"}), 400 if not _project_dir(slug).exists(): return jsonify({"error": "專案不存在"}), 404 action = request.args.get("action", "").strip() # ── 處理重新命名 / 複製頁面 ── 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_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 if not old_path.exists(): return jsonify({"error": "來源頁面不存在"}), 404 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 = "頁面複製成功" else: 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": 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), '/')}" }) # ── 處理刪除頁面 ── elif action == "delete": file = str(body.get("file", "")).strip() if not file: return jsonify({"error": "缺少參數 file"}), 400 if file.lower() == "index.html": return jsonify({"error": "無法刪除主頁 index.html"}), 400 safe_path = _sanitize_file_path(slug, file) if safe_path is None: return jsonify({"error": "不合法的頁面名稱"}), 400 if not safe_path.exists(): return jsonify({"error": "頁面不存在"}), 404 safe_path.unlink() return jsonify({ "success": True, "ok": True, "message": "頁面已成功刪除" }) # 1. 判斷是否為新增頁面請求 (含有 startTemplateUrl) start_template_url = str(body.get("startTemplateUrl", "")).strip() if start_template_url: title = str(body.get("title", "")).strip() or "New Page" 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, folder) if safe_path is None: return jsonify({"error": "不合法的頁面名稱"}), 400 if safe_path.exists(): return jsonify({"error": "頁面已存在"}), 409 # 建立子資料夾(若需要) 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) else: _copy_blank_template(safe_path) # 回傳 VvvebJS FileManager 所期待的 JSON 格式 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": str(rel_path).replace("\\", "/"), "url": f"/sites/{slug}/{str(rel_path).replace(chr(92), '/')}" }) # 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_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": str(safe_path.relative_to(_project_dir(slug))).replace("\\", "/")}) @app.route("/sites//") # type: ignore[untyped-decorator] def serve_site_file(slug: str, filename: str) -> Response: proj_dir = _project_dir(slug) if not proj_dir.exists(): abort(404) return send_from_directory(proj_dir, filename) # ── 啟動 ──────────────────────────────────────────────────────────────────── if __name__ == "__main__": WEBSITES_DIR.mkdir(exist_ok=True) app.run(debug=True, port=5000)