diff --git a/build_editor.py b/build_editor.py new file mode 100644 index 0000000..597412d --- /dev/null +++ b/build_editor.py @@ -0,0 +1,102 @@ +"""Build editor.html template from vvvebjs source with Jinja2 escaping.""" +from pathlib import Path + +BASE = Path(__file__).parent +SRC = BASE / "static" / "Vvvebjs" / "editor.html" +OUT = BASE / "templates" / "editor.html" + +content = SRC.read_text(encoding="utf-8") + +# ── 1. Fix static asset paths ──────────────────────────────────── +VBASE = "/static/Vvvebjs/" +replacements = [ + ('href="css/', f'href="{VBASE}css/'), + ('href="libs/', f'href="{VBASE}libs/'), + ('href="img/', f'href="{VBASE}img/'), + ('href="favicon.ico"', f'href="{VBASE}favicon.ico"'), + ('src="libs/', f'src="{VBASE}libs/'), + ('src="js/', f'src="{VBASE}js/'), + ('src="img/', f'src="{VBASE}img/'), + ('src="media/', f'src="{VBASE}media/'), + ('src="fonts/', f'src="{VBASE}fonts/'), + ('src="demo/', f'src="{VBASE}demo/'), +] +for old, new in replacements: + content = content.replace(old, new) + +# ── 2. Fix PHP save URL → Flask API ───────────────────────────── +content = content.replace('data-vvveb-url="save.php"', 'data-vvveb-url="/api/save"') + +# ── 3. Fix JS url variables ────────────────────────────────────── +JS_VARS = [ + ("let renameUrl = 'save.php?action=rename';", + "let renameUrl = '/api/save?action=rename';"), + ("let deleteUrl = 'save.php?action=delete';", + "let deleteUrl = '/api/save?action=delete';"), + ("let saveReusableUrl = 'save.php?action=saveReusable';", + "let saveReusableUrl = '/api/save?action=saveReusable';"), + ("let oEmbedProxyUrl = 'save.php?action=oembedProxy';", + "let oEmbedProxyUrl = '/api/save?action=oembedProxy';"), +] +for old, new in JS_VARS: + content = content.replace(old, new) + +# ── 4. Wrap everything in {% raw %} ... {% endraw %} to avoid Jinja2 parsing conflicts ── +# We do this first so Jinja2 ignores VvvebJS's frontend micro-templates ({%= %}, {% %}). +# Then we selectively exit the {% raw %} block using {% endraw %} ... {% raw %} for our dynamic values. +content = "{% raw %}" + content + "{% endraw %}" + +# ── 5. Replace defaultPages block with Escaped Jinja2 Injection ── +# Since we are inside a {% raw %} block, we use {% endraw %}{{ pages_json | safe }}{% raw %} +start = content.find("let defaultPages = {") +end = content.find("};\n\n\nlet pages = defaultPages;") +if start != -1 and end != -1: + end += len("};\n\n\nlet pages = defaultPages;") + content = ( + content[:start] + + "let defaultPages = {% endraw %}{{ pages_json | safe }}{% raw %};\nlet pages = defaultPages;" + + content[end:] + ) + +# ── 6. Add back button + slug variable before
─────────── +# We break out of {% raw %} using {% endraw %} to interpolate {{ slug | safe }} +BACK_BTN = """ + + + ← 返回管理器 + + + +""" +content = content.replace("", BACK_BTN + "\n") + +OUT.write_text(content, encoding="utf-8") +print(f"Successfully generated escaped Jinja2 template! Size: {len(content)} bytes.") diff --git a/main.py b/main.py index fb9c894..13394e5 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,295 @@ -def main(): - print("Hello from vvvebjs!") +"""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( + "