feat: composer improvements
This commit is contained in:
192
backend/pages.py
192
backend/pages.py
@@ -1,13 +1,15 @@
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import uframe
|
||||
|
||||
router = APIRouter()
|
||||
files_router = APIRouter()
|
||||
|
||||
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
||||
@@ -38,7 +40,7 @@ page "Welcome" 60
|
||||
|
||||
|
||||
def ensure_default_pages():
|
||||
"""Create a default index page if none exists."""
|
||||
"""Create default index page and .env file if they don't exist."""
|
||||
PAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -54,6 +56,17 @@ def ensure_default_pages():
|
||||
if not index_src.is_file():
|
||||
index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8")
|
||||
|
||||
env_path = SOURCES_DIR / ".env"
|
||||
if not env_path.is_file():
|
||||
env_path.write_text(
|
||||
"# Environment variables for dynamic pages\n"
|
||||
"# Access with: source name : env \"KEY\"\n"
|
||||
"#\n"
|
||||
"# Example:\n"
|
||||
"# API_KEY=your-key-here\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class PageMeta(BaseModel):
|
||||
name: str
|
||||
@@ -225,3 +238,176 @@ async def delete_page(name: str):
|
||||
mu_path.unlink()
|
||||
|
||||
return {"deleted": name}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File browser endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FileEntry(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
type: str # "file" | "folder" | "env"
|
||||
size: int | None = None
|
||||
last_modified: float | None = None
|
||||
title: str | None = None
|
||||
published: bool = False
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
class MoveRequest(BaseModel):
|
||||
model_config = {"populate_by_name": True}
|
||||
from_path: str = Field(alias="from")
|
||||
to: str
|
||||
|
||||
|
||||
class EnvRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
def _validate_relative_path(rel: str) -> Path:
|
||||
"""Validate that a relative path has no traversal components and resolves
|
||||
inside the expected base directories. Returns the cleaned relative Path."""
|
||||
p = Path(rel)
|
||||
# Reject absolute paths and any ".." components
|
||||
if p.is_absolute():
|
||||
raise HTTPException(status_code=400, detail="Absolute paths not allowed")
|
||||
for part in p.parts:
|
||||
if part == "..":
|
||||
raise HTTPException(status_code=400, detail="Directory traversal not allowed")
|
||||
# Extra safety: resolve against SOURCES_DIR and verify containment
|
||||
resolved = (SOURCES_DIR / p).resolve()
|
||||
if not str(resolved).startswith(str(SOURCES_DIR.resolve())):
|
||||
raise HTTPException(status_code=400, detail="Path escapes base directory")
|
||||
return p
|
||||
|
||||
|
||||
def _file_entry(base: Path, rel_path: Path) -> FileEntry:
|
||||
"""Build a FileEntry for a file or directory at base/rel_path."""
|
||||
full = base / rel_path
|
||||
name = rel_path.name
|
||||
|
||||
if full.is_dir():
|
||||
return FileEntry(
|
||||
name=name,
|
||||
path=str(rel_path),
|
||||
type="folder",
|
||||
)
|
||||
|
||||
# .env file
|
||||
if name == ".env":
|
||||
stat = full.stat()
|
||||
return FileEntry(
|
||||
name=name,
|
||||
path=str(rel_path),
|
||||
type="env",
|
||||
size=stat.st_size,
|
||||
last_modified=stat.st_mtime,
|
||||
)
|
||||
|
||||
# Regular file
|
||||
stat = full.stat()
|
||||
title = None
|
||||
published = False
|
||||
|
||||
if full.suffix == ".uf":
|
||||
try:
|
||||
title = _extract_title(full.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
# Check published status: corresponding .mu in PAGES_DIR at same relative path
|
||||
mu_rel = rel_path.with_suffix(".mu")
|
||||
published = (PAGES_DIR / mu_rel).is_file()
|
||||
|
||||
return FileEntry(
|
||||
name=name,
|
||||
path=str(rel_path),
|
||||
type="file",
|
||||
size=stat.st_size,
|
||||
last_modified=stat.st_mtime,
|
||||
title=title,
|
||||
published=published,
|
||||
)
|
||||
|
||||
|
||||
@files_router.get("/files", response_model=list[FileEntry])
|
||||
async def list_files(path: str = Query(default="")):
|
||||
"""List files and folders in SOURCES_DIR, optionally scoped to a subfolder."""
|
||||
if path:
|
||||
rel = _validate_relative_path(path)
|
||||
else:
|
||||
rel = Path(".")
|
||||
|
||||
target = (SOURCES_DIR / rel).resolve()
|
||||
if not str(target).startswith(str(SOURCES_DIR.resolve())):
|
||||
raise HTTPException(status_code=400, detail="Path escapes base directory")
|
||||
if not target.is_dir():
|
||||
raise HTTPException(status_code=404, detail="Directory not found")
|
||||
|
||||
entries: list[FileEntry] = []
|
||||
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
||||
item_rel = item.relative_to(SOURCES_DIR)
|
||||
entries.append(_file_entry(SOURCES_DIR, item_rel))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
@files_router.post("/files/mkdir")
|
||||
async def mkdir(req: MkdirRequest):
|
||||
"""Create a folder in both SOURCES_DIR and PAGES_DIR."""
|
||||
rel = _validate_relative_path(req.path)
|
||||
|
||||
(SOURCES_DIR / rel).mkdir(parents=True, exist_ok=True)
|
||||
(PAGES_DIR / rel).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return {"created": str(rel)}
|
||||
|
||||
|
||||
@files_router.post("/files/move")
|
||||
async def move_file(req: MoveRequest):
|
||||
"""Move/rename a file or folder in both SOURCES_DIR and PAGES_DIR."""
|
||||
from_rel = _validate_relative_path(req.from_path)
|
||||
to_rel = _validate_relative_path(req.to)
|
||||
|
||||
# Move in SOURCES_DIR
|
||||
src_from = SOURCES_DIR / from_rel
|
||||
src_to = SOURCES_DIR / to_rel
|
||||
if src_from.exists():
|
||||
src_to.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src_from), str(src_to))
|
||||
|
||||
# Move in PAGES_DIR (for .uf files, look for .mu counterpart)
|
||||
if src_from.suffix == ".uf" or (not src_from.exists() and from_rel.suffix == ".uf"):
|
||||
pages_from = PAGES_DIR / from_rel.with_suffix(".mu")
|
||||
pages_to = PAGES_DIR / to_rel.with_suffix(".mu")
|
||||
else:
|
||||
pages_from = PAGES_DIR / from_rel
|
||||
pages_to = PAGES_DIR / to_rel
|
||||
|
||||
if pages_from.exists():
|
||||
pages_to.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(pages_from), str(pages_to))
|
||||
|
||||
return {"moved": {"from": str(from_rel), "to": str(to_rel)}}
|
||||
|
||||
|
||||
@files_router.get("/files/env")
|
||||
async def read_env():
|
||||
"""Read the .env file from SOURCES_DIR root."""
|
||||
env_path = SOURCES_DIR / ".env"
|
||||
if env_path.is_file():
|
||||
return {"content": env_path.read_text(encoding="utf-8")}
|
||||
return {"content": ""}
|
||||
|
||||
|
||||
@files_router.post("/files/env")
|
||||
async def save_env(req: EnvRequest):
|
||||
"""Save the .env file to SOURCES_DIR root."""
|
||||
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
env_path = SOURCES_DIR / ".env"
|
||||
env_path.write_text(req.content, encoding="utf-8")
|
||||
return {"saved": True}
|
||||
|
||||
Reference in New Issue
Block a user