"""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(p.name for p in proj_dir.glob("*.html")) 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")) 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) -> Path | None: """驗證並回傳安全的檔案路徑(防止路徑遍歷).""" proj_dir = _project_dir(slug).resolve() target = (proj_dir / Path(filename).name).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) project = _load_project(slug) pages_obj: dict[str, Any] = {} for page in pages: name = page.replace(".html", "") pages_obj[name] = { "name": name, "title": name.replace("-", " ").title(), "filename": page, "file": page, "url": f"/sites/{slug}/{page}", } 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() filename: str = str(body.get("file", "")).strip() html: str = str(body.get("html", "")).strip() if not slug or not filename or not html: return jsonify({"error": "缺少必要參數 slug / file / html"}), 400 if not _project_dir(slug).exists(): return jsonify({"error": "專案不存在"}), 404 safe_path = _sanitize_file_path(slug, filename) if safe_path is None: return jsonify({"error": "不合法的檔案路徑"}), 400 safe_path.write_text(html, encoding="utf-8") return jsonify({"ok": True, "saved": safe_path.name}) @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)