Add image upload support: assets dir, POST /api/assets, editor drop zone

This commit is contained in:
brutsalvadi
2026-05-04 12:21:15 +02:00
parent 0f8bd3dba6
commit 83f48f09d4
4 changed files with 52 additions and 4 deletions
+5
View File
@@ -4,6 +4,7 @@ __pycache__/
.venv/ .venv/
venv/ venv/
.python-version .python-version
.idea*
# Site build output (managed by submodule) # Site build output (managed by submodule)
site/dist/ site/dist/
@@ -20,5 +21,9 @@ data/
deployment/ deployment/
deploy/vps/ deploy/vps/
# User-uploaded assets (out-of-band; rsync'd to VPS separately, not versioned)
assets/*
!assets/.gitkeep
# OS # OS
.DS_Store .DS_Store
+6
View File
@@ -14,6 +14,7 @@ bincio_wiki/
pages/ wiki content (*.md files the community edits) pages/ wiki content (*.md files the community edits)
_docs/ software documentation (shown as separate section) _docs/ software documentation (shown as separate section)
blog/ blog/stories content (*.md files) 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) site/ Astro 6 app (git submodule → brutsalvadi/astro-bloomz)
edit/ FastAPI edit server (Python, port 8001) edit/ FastAPI edit server (Python, port 8001)
pyproject.toml Python dependencies (managed by uv) 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`) without extension, e.g. `digital-garden`)
- Docs section: `pages/_docs/*.md` (IDs start with `_docs/`) - Docs section: `pages/_docs/*.md` (IDs start with `_docs/`)
- Blog/stories: `blog/*.md` - 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 - Bonsai index: stored in `site/src/content/index/` — the `i.bonsai.md` file
defines the semantic tree structure defines the semantic tree structure
- The homepage (`site/src/pages/index.astro`) splits entries by `_docs/` prefix - The homepage (`site/src/pages/index.astro`) splits entries by `_docs/` prefix
+40 -3
View File
@@ -14,16 +14,18 @@ from pathlib import Path
from typing import Optional from typing import Optional
import bcrypt 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.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
_ROOT = Path(__file__).parent.parent _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") 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. # 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 _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" _SESSION_COOKIE = "bincio_session"
_SAFE_SLUG = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_\-/]*$") _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 ───────────────────────────────────────────────────────── # ── Shared DB helpers ─────────────────────────────────────────────────────────
@@ -272,6 +286,24 @@ async def delete_story(slug: str, user: User = Depends(require_auth)) -> JSONRes
return _delete(slug, stories_dir) 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") @app.post("/rebuild")
async def rebuild(user: User = Depends(require_auth)) -> JSONResponse: async def rebuild(user: User = Depends(require_auth)) -> JSONResponse:
try: try:
@@ -302,3 +334,8 @@ async def rebuild(user: User = Depends(require_auth)) -> JSONResponse:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(500, str(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")
+1 -1
Submodule site updated: 519e74de96...3e82221af5