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
+40 -3
View File
@@ -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")