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

296 lines
10 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()
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/<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)