基本架構
This commit is contained in:
295
main.py
295
main.py
@@ -1,6 +1,295 @@
|
||||
def main():
|
||||
print("Hello from vvvebjs!")
|
||||
"""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__":
|
||||
main()
|
||||
WEBSITES_DIR.mkdir(exist_ok=True)
|
||||
app.run(debug=True, port=5000)
|
||||
|
||||
Reference in New Issue
Block a user