Files
vvveb-cms/main.py
2026-05-17 22:44:11 +08:00

334 lines
12 KiB
Python
Raw 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(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(
"<!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)
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/<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:
return jsonify({"error": "缺少專案識別碼 (slug)"}), 400
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
# 1. 判斷是否為新增頁面請求 (含有 startTemplateUrl)
start_template_url = str(body.get("startTemplateUrl", "")).strip()
if start_template_url:
title = str(body.get("title", "")).strip() or "New Page"
# 取得安全的檔名 (只取檔名部分,例如 'about.html')
filename = Path(str(body.get("file", "untitled.html")).strip()).name
if not filename.endswith(".html"):
filename += ".html"
safe_path = _sanitize_file_path(slug, filename)
if safe_path is None:
return jsonify({"error": "不合法的頁面名稱"}), 400
if safe_path.exists():
return jsonify({"error": "頁面已存在"}), 409
# 解析並複製樣板 (定位在 static/Vvvebjs 目錄下)
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 格式
page_name = safe_path.stem
return jsonify({
"ok": True,
"name": page_name,
"title": title,
"file": safe_path.name,
"url": f"/sites/{slug}/{safe_path.name}"
})
# 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)
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/<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)