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