diff --git a/.gitignore b/.gitignore index 14512fb..8d3332e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ __pycache__/ .venv/ venv/ .python-version +.idea* # Site build output (managed by submodule) site/dist/ @@ -20,5 +21,9 @@ data/ deployment/ deploy/vps/ +# User-uploaded assets (out-of-band; rsync'd to VPS separately, not versioned) +assets/* +!assets/.gitkeep + # OS .DS_Store diff --git a/CLAUDE.md b/CLAUDE.md index 4074efd..b2c2f2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ bincio_wiki/ pages/ wiki content (*.md files the community edits) _docs/ software documentation (shown as separate section) blog/ blog/stories content (*.md files) + assets/ user-uploaded images (gitignored; rsync'd to VPS separately) site/ Astro 6 app (git submodule → brutsalvadi/astro-bloomz) edit/ FastAPI edit server (Python, port 8001) pyproject.toml Python dependencies (managed by uv) @@ -39,6 +40,11 @@ by `dev.sh --edit` — no manual setup needed. To add a dependency: without extension, e.g. `digital-garden`) - Docs section: `pages/_docs/*.md` (IDs start with `_docs/`) - Blog/stories: `blog/*.md` +- Assets: `assets/` — gitignored, not part of the Astro build. FastAPI serves them + at `/assets/{filename}` in dev (Vite proxies `/assets` → port 8001). On the VPS, + nginx serves `/assets/` directly from the filesystem (rsync'd separately). + Upload via `POST /api/assets` (multipart); returns `{"url": "/assets/filename"}`. + The editor inserts `![name](/assets/filename)` into the markdown on upload. - Bonsai index: stored in `site/src/content/index/` — the `i.bonsai.md` file defines the semantic tree structure - The homepage (`site/src/pages/index.astro`) splits entries by `_docs/` prefix diff --git a/edit/server.py b/edit/server.py index 43d6515..c68466b 100644 --- a/edit/server.py +++ b/edit/server.py @@ -14,16 +14,18 @@ from pathlib import Path from typing import Optional import bcrypt -from fastapi import Cookie, Depends, FastAPI, HTTPException +from fastapi import Cookie, Depends, FastAPI, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles from pydantic import BaseModel _ROOT = Path(__file__).parent.parent -pages_dir: Path = _ROOT / os.environ.get("WIKI_PAGES_DIR", "pages") +pages_dir: Path = _ROOT / os.environ.get("WIKI_PAGES_DIR", "pages") stories_dir: Path = _ROOT / os.environ.get("WIKI_STORIES_DIR", "blog") -site_dir: Path = _ROOT / "site" +assets_dir: Path = _ROOT / os.environ.get("WIKI_ASSETS_DIR", "assets") +site_dir: Path = _ROOT / "site" # On VPS, copy built dist here so nginx can serve from /var/www with proper permissions. _wiki_webroot: Path | None = Path(os.environ["WIKI_WEBROOT"]) if os.environ.get("WIKI_WEBROOT") else None @@ -38,6 +40,18 @@ _SESSION_TTL = 30 * 24 * 3600 # 30 days (matches bincio_activity) _SESSION_COOKIE = "bincio_session" _SAFE_SLUG = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_\-/]*$") +_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} +_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB + + +def _unique_name(directory: Path, filename: str) -> str: + stem, suffix = Path(filename).stem, Path(filename).suffix + candidate = filename + counter = 1 + while (directory / candidate).exists(): + candidate = f"{stem}_{counter}{suffix}" + counter += 1 + return candidate # ── Shared DB helpers ───────────────────────────────────────────────────────── @@ -272,6 +286,24 @@ async def delete_story(slug: str, user: User = Depends(require_auth)) -> JSONRes return _delete(slug, stories_dir) +# ── Asset upload ───────────────────────────────────────────────────────────── + +@app.post("/api/assets") +async def upload_asset(file: UploadFile = File(...), user: User = Depends(require_auth)) -> JSONResponse: + if not file.filename: + raise HTTPException(400, "No filename") + ct = file.content_type or "" + if ct not in _ALLOWED_IMAGE_TYPES: + raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted") + contents = await file.read() + if len(contents) > _MAX_IMAGE_BYTES: + raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024 * 1024)} MB)") + assets_dir.mkdir(parents=True, exist_ok=True) + safe_name = _unique_name(assets_dir, Path(file.filename).name) + (assets_dir / safe_name).write_bytes(contents) + return JSONResponse({"ok": True, "filename": safe_name, "url": f"/assets/{safe_name}"}) + + @app.post("/rebuild") async def rebuild(user: User = Depends(require_auth)) -> JSONResponse: try: @@ -302,3 +334,8 @@ async def rebuild(user: User = Depends(require_auth)) -> JSONResponse: raise except Exception as e: raise HTTPException(500, str(e)) + + +# Serve uploaded assets. Must be mounted after all route definitions. +assets_dir.mkdir(parents=True, exist_ok=True) +app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") diff --git a/site b/site index 519e74d..3e82221 160000 --- a/site +++ b/site @@ -1 +1 @@ -Subproject commit 519e74de96b30a7abd55ffd6bc8e50682aac6d29 +Subproject commit 3e82221af5c494e35b8ae5f24a8705957e88469d