Files
vvveb-cms/main.py

599 lines
22 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
import os
import uuid
from flask import Flask, Response, abort, jsonify, render_template, request, send_from_directory
from werkzeug.utils import secure_filename
from utils.media_manager import save_upload, list_media, delete_media
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",
)
def build_page_tree(slug: str, max_depth: int = 3) -> dict[str, Any]:
"""遞歸建構專案的頁面樹狀結構(根據資料夾結構)。"""
proj_dir = _project_dir(slug)
def scan_folder(folder: Path, current_depth: int = 0):
items: list[dict[str, Any]] = []
if current_depth > max_depth:
return items
try:
names = sorted([p.name for p in folder.iterdir()])
except Exception:
return items
for name in names:
if name.startswith("."):
continue
full = folder / name
if full.is_dir():
children = scan_folder(full, current_depth + 1)
items.append({
"type": "folder",
"name": name,
"title": name.replace("-", " ").title(),
"children": children,
})
elif full.is_file() and full.suffix.lower() == ".html":
rel = str(full.relative_to(proj_dir)).replace("\\", "/")
items.append({
"type": "file",
"name": rel,
"title": Path(name).stem.replace("-", " ").title(),
})
return items
return {"root": scan_folder(proj_dir, 0)}
# ── 路由:一般頁面 ──────────────────────────────────────────────────────────
@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:
# render projects overview
return str(render_template("dashboard.html"))
@app.route("/dashboard/project/<slug>") # type: ignore[untyped-decorator]
def project_dashboard(slug: str) -> str:
# render single project management dashboard
proj_dir = _project_dir(slug)
if not proj_dir.exists():
abort(404)
return str(render_template("project_dashboard.html", slug=slug))
@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/<slug>/settings", methods=["GET"]) # type: ignore[untyped-decorator]
def api_get_settings(slug: str) -> tuple[Response, int] | Response:
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
data = _load_project(slug)
settings = data.get("settings", {})
# provide sensible defaults
defaults = {
"title": data.get("name", slug),
"description": data.get("description", ""),
"logo_url": None,
"favicon_url": None,
"primary_color": "#007bff",
"secondary_color": "#6c757d",
}
merged = {**defaults, **settings}
return jsonify(merged)
@app.route("/api/projects/<slug>/settings", methods=["PUT"]) # type: ignore[untyped-decorator]
def api_put_settings(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)
settings = data.get("settings", {})
settings.update(body)
data["settings"] = settings
data["updated_at"] = _now_iso()
_save_project(slug, data)
return jsonify({"ok": True, "settings": settings})
@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-tree", methods=["GET"]) # type: ignore[untyped-decorator]
def api_pages_tree(slug: str) -> tuple[Response, int] | Response:
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
tree = build_page_tree(slug, max_depth=5)
return jsonify(tree)
@app.route("/api/projects/<slug>/pages-tree", methods=["PUT"]) # type: ignore[untyped-decorator]
def api_put_pages_tree(slug: str) -> tuple[Response, int] | Response:
"""儲存自訂的頁面樹狀結構到 project.json 的 page_tree 欄位。"""
if not _project_dir(slug).exists():
return jsonify({"error": "專案不存在"}), 404
body: dict[str, Any] = request.get_json(force=True) or {}
if not isinstance(body, dict):
return jsonify({"error": "不正確的資料格式"}), 400
data = _load_project(slug)
data["page_tree"] = body
data["updated_at"] = _now_iso()
_save_project(slug, data)
return jsonify({"ok": True, "page_tree": body})
@app.route("/api/projects/<slug>/media/upload", methods=["POST"]) # type: ignore[untyped-decorator]
def api_media_upload(slug: str) -> tuple[Response, int] | Response:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return jsonify({"error": "專案不存在"}), 404
if "file" not in request.files:
return jsonify({"error": "缺少檔案 (file)"}), 400
f = request.files["file"]
if f.filename == "":
return jsonify({"error": "檔名為空"}), 400
# basic validation
filename = secure_filename(f.filename)
meta = save_upload(proj_dir, f)
# substitute slug in returned urls
if isinstance(meta.get("url"), str):
meta["url"] = meta["url"].replace("{slug}", slug)
if isinstance(meta.get("thumb"), str):
meta["thumb"] = meta["thumb"].replace("{slug}", slug)
return jsonify({"ok": True, "file": meta})
@app.route("/api/projects/<slug>/media/list", methods=["GET"]) # type: ignore[untyped-decorator]
def api_media_list(slug: str) -> tuple[Response, int] | Response:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return jsonify({"error": "專案不存在"}), 404
items = list_media(proj_dir)
# patch urls
for it in items:
if "filename" in it and "date" in it:
it["url"] = f"/sites/{slug}/media/images/{it['date']}/{it['filename']}"
return jsonify(items)
@app.route("/api/projects/<slug>/media/<path:rel_path>", methods=["DELETE"]) # type: ignore[untyped-decorator]
def api_media_delete(slug: str, rel_path: str) -> tuple[Response, int] | Response:
proj_dir = _project_dir(slug)
if not proj_dir.exists():
return jsonify({"error": "專案不存在"}), 404
ok = delete_media(proj_dir, rel_path)
if not ok:
return jsonify({"error": "刪除失敗或檔案不存在"}), 400
return jsonify({"ok": True})
@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)