Files
vvveb-cms/main.py

334 lines
12 KiB
Python
Raw Normal View History

2026-05-17 22:11:46 +08:00
"""VvvebJS 網頁管理器 — Flask 主程式."""
2026-05-17 21:10:00 +08:00
2026-05-17 22:11:46 +08:00
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:
2026-05-17 22:44:11 +08:00
"""接受 vvvebjs 的儲存或新增頁面請求,寫入對應專案目錄."""
2026-05-17 22:11:46 +08:00
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()
2026-05-17 22:44:11 +08:00
if not slug:
return jsonify({"error": "缺少專案識別碼 (slug)"}), 400
2026-05-17 22:11:46 +08:00
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
2026-05-17 22:44:11 +08:00
# 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
2026-05-17 22:11:46 +08:00
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})
2026-05-17 22:44:11 +08:00
2026-05-17 22:11:46 +08:00
@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)
# ── 啟動 ────────────────────────────────────────────────────────────────────
2026-05-17 21:10:00 +08:00
if __name__ == "__main__":
2026-05-17 22:11:46 +08:00
WEBSITES_DIR.mkdir(exist_ok=True)
app.run(debug=True, port=5000)