1. Image upload size limit — _MAX_IMAGE_BYTES = 10 MB in both serve/server.py and edit/server.py
2. Image MIME type whitelist — _ALLOWED_IMAGE_TYPES blocks SVG XSS in both servers 3. Filename collision safety — _unique_image_name() helper in both servers 4. OAuth CSRF — state token generated in edit/server.py auth-url, stored in _oauth_states, validated and discarded in callback; strava_api.auth_url() accepts optional state param 5. Error message leak — upload processing errors now return generic "Processing failed" instead of exception type/message 6. Handle injection in subprocess — _trigger_rebuild now asserts handle matches _VALID_HANDLE before passing to subprocess
This commit is contained in:
+93
-5
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -142,12 +143,31 @@ def _set_session_cookie(response: Response, token: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
# ── Image upload constants ────────────────────────────────────────────────────
|
||||
|
||||
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def _unique_image_name(directory: Path, filename: str) -> str:
|
||||
"""Return a filename that does not collide with existing files in directory."""
|
||||
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
|
||||
|
||||
|
||||
# ── Post-write rebuild ────────────────────────────────────────────────────────
|
||||
|
||||
def _trigger_rebuild(handle: str) -> None:
|
||||
"""Asynchronously re-merge one user's shard and rewrite the root manifest."""
|
||||
if site_dir is None:
|
||||
return
|
||||
if not _VALID_HANDLE.match(handle):
|
||||
return # safety: never pass untrusted strings to subprocess
|
||||
subprocess.Popen(
|
||||
["uv", "run", "bincio", "render",
|
||||
"--data-dir", str(data_dir),
|
||||
@@ -386,12 +406,15 @@ async def upload_image(
|
||||
if not file.filename:
|
||||
raise HTTPException(400, "No filename")
|
||||
ct = file.content_type or ""
|
||||
if not ct.startswith("image/"):
|
||||
raise HTTPException(400, f"Only image files accepted (got {ct})")
|
||||
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)")
|
||||
images_dir = dd / "edits" / "images" / activity_id
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = Path(file.filename).name
|
||||
(images_dir / safe_name).write_bytes(await file.read())
|
||||
safe_name = _unique_image_name(images_dir, Path(file.filename).name)
|
||||
(images_dir / safe_name).write_bytes(contents)
|
||||
_trigger_rebuild(user.handle)
|
||||
return JSONResponse({"ok": True, "filename": safe_name})
|
||||
|
||||
@@ -537,7 +560,7 @@ async def upload_activity(
|
||||
results.append({"name": name, "ok": True, "id": activity_id})
|
||||
any_added = True
|
||||
except Exception as exc:
|
||||
results.append({"name": name, "ok": False, "error": f"{type(exc).__name__}: {exc}"})
|
||||
results.append({"name": name, "ok": False, "error": "Processing failed"})
|
||||
finally:
|
||||
if not kept:
|
||||
staged.unlink(missing_ok=True)
|
||||
@@ -550,6 +573,71 @@ async def upload_activity(
|
||||
return JSONResponse({"ok": True, "added": len(added), "results": results})
|
||||
|
||||
|
||||
# ── Feedback ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
|
||||
_FEEDBACK_MAX_IMAGES = 3
|
||||
_FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB
|
||||
|
||||
|
||||
@app.post("/api/feedback")
|
||||
async def submit_feedback(
|
||||
text: str = Form(""),
|
||||
images: list[UploadFile] = File(default=[]),
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> JSONResponse:
|
||||
user = _require_user(bincio_session)
|
||||
|
||||
text = text.strip()
|
||||
if not text and not any(f.filename for f in images):
|
||||
raise HTTPException(400, "Feedback must include text or at least one image")
|
||||
if len(images) > _FEEDBACK_MAX_IMAGES:
|
||||
raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission")
|
||||
|
||||
feedback_dir = _get_data_dir() / "_feedback"
|
||||
feedback_dir.mkdir(exist_ok=True)
|
||||
images_dir = feedback_dir / user.handle
|
||||
images_dir.mkdir(exist_ok=True)
|
||||
|
||||
now = int(time.time())
|
||||
submission_id = f"{now}_{secrets.token_hex(4)}"
|
||||
saved_images: list[str] = []
|
||||
|
||||
for img in images:
|
||||
if not img.filename:
|
||||
continue
|
||||
suffix = Path(img.filename).suffix.lower()
|
||||
if suffix not in _FEEDBACK_IMAGE_SUFFIXES:
|
||||
raise HTTPException(400, f"Unsupported image type '{suffix}'")
|
||||
contents = await img.read()
|
||||
if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES:
|
||||
raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit")
|
||||
safe_name = f"{submission_id}_{Path(img.filename).name}"
|
||||
(images_dir / safe_name).write_bytes(contents)
|
||||
saved_images.append(safe_name)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
entry = {
|
||||
"id": submission_id,
|
||||
"handle": user.handle,
|
||||
"submitted_at": datetime.now(timezone.utc).isoformat(),
|
||||
"text": text,
|
||||
"images": saved_images,
|
||||
}
|
||||
|
||||
log_file = feedback_dir / f"{user.handle}.json"
|
||||
existing: list[dict] = []
|
||||
if log_file.exists():
|
||||
try:
|
||||
existing = json.loads(log_file.read_text())
|
||||
except Exception:
|
||||
existing = []
|
||||
existing.append(entry)
|
||||
log_file.write_text(json.dumps(existing, indent=2))
|
||||
|
||||
return JSONResponse({"ok": True, "id": submission_id})
|
||||
|
||||
|
||||
@app.post("/api/strava/sync")
|
||||
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = _require_user(bincio_session)
|
||||
|
||||
Reference in New Issue
Block a user