Files
vvveb-cms/main.py
2026-05-18 14:26:46 +08:00

445 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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(
"<!DOCTYPE html><html><head><title>My Page</title></head><body></body></html>",
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/<slug>") # 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/<slug>", 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/<slug>", 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/<slug>", 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/<slug>/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/<slug>/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/<slug>/<path:filename>") # 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)