Add image upload support: assets dir, POST /api/assets, editor drop zone
This commit is contained in:
+40
-3
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user