chore: add docker infrastructure, gists feature, attachment system, and admin CLI tools

- Add .dockerignore, Dockerfile, and docker compose targets to Makefile for containerized deployment
- Implement gists router with CRUD operations, database schema, and polymorphic comment/vote reuse
- Create attachment upload system with thumbnail generation, MIME detection, and storage path management
- Add attachments_prune CLI command to clean orphaned attachment records and files
- Introduce rate limiting middleware with 60 requests per minute window
- Add custom 404 and 500 error handlers with SEO-optimized template responses
- Extend database initialization with gists and attachments indexes plus upload site settings defaults
- Update load_comments to include attachment mapping for comment resources
- Register gists and uploads routers in main application and update AGENTS.md documentation
This commit is contained in:
2026-05-12 13:07:34 +00:00
parent ff2585b098
commit aa88f18c03
69 changed files with 3069 additions and 450 deletions
+116
View File
@@ -0,0 +1,116 @@
import hashlib
import logging
import aiofiles
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_setting
from devplacepy.config import STATIC_DIR
from devplacepy.utils import generate_uid, require_user
logger = logging.getLogger(__name__)
router = APIRouter()
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"}
def _store_file(content: bytes, original_filename: str) -> tuple[str, str]:
uid = generate_uid()
ext = Path(original_filename).suffix.lower() or ""
stored_name = f"{uid}{ext}"
hash_str = hashlib.sha256(stored_name.encode()).hexdigest()
subdir = f"{hash_str[:2]}/{hash_str[2:4]}"
storage_path = f"{subdir}/{stored_name}"
full_dir = STATIC_DIR / "uploads" / subdir
full_dir.mkdir(parents=True, exist_ok=True)
full_path = full_dir / stored_name
with open(str(full_path), "wb") as f:
f.write(content)
return uid, storage_path
@router.post("/upload")
async def upload_file(request: Request):
user = require_user(request)
form = await request.form()
file = form.get("file")
if not file or not hasattr(file, "filename") or not file.filename:
return JSONResponse({"error": "No file provided"}, status_code=400)
max_size_mb = int(get_setting("max_upload_size_mb", "10"))
max_size_bytes = max_size_mb * 1024 * 1024
allowed_types_raw = get_setting("allowed_file_types", "").strip()
allowed_extensions = set()
if allowed_types_raw:
for ext in allowed_types_raw.split(","):
ext = ext.strip().lower()
if ext.startswith("."):
allowed_extensions.add(ext)
else:
allowed_extensions.add(f".{ext}")
try:
content = await file.read()
except Exception as e:
logger.warning(f"Failed to read uploaded file: {e}")
return JSONResponse({"error": "Failed to read file"}, status_code=400)
if len(content) > max_size_bytes:
return JSONResponse({"error": f"File exceeds {max_size_mb}MB limit"}, status_code=413)
ext = Path(file.filename).suffix.lower()
if allowed_extensions and ext not in allowed_extensions:
return JSONResponse({"error": f"File type '{ext}' not allowed"}, status_code=415)
uid, storage_path = _store_file(content, file.filename)
attachments = get_table("attachments")
attachments.insert({
"uid": uid,
"resource_uid": "",
"resource_type": "",
"original_filename": file.filename,
"stored_filename": f"{uid}{ext}",
"mime_type": file.content_type or "application/octet-stream",
"file_size": len(content),
"storage_path": storage_path,
"created_at": __import__("datetime").datetime.utcnow().isoformat(),
})
is_image = ext in IMAGE_EXTENSIONS
file_url = f"/static/uploads/{storage_path}"
logger.info(f"File uploaded: {file.filename} ({len(content)} bytes) -> {storage_path}")
return JSONResponse({
"uid": uid,
"original_filename": file.filename,
"url": file_url,
"mime_type": file.content_type or "application/octet-stream",
"file_size": len(content),
"is_image": is_image,
}, status_code=201)
@router.delete("/delete/{attachment_uid}")
async def delete_attachment(request: Request, attachment_uid: str):
user = require_user(request)
attachments = get_table("attachments")
att = attachments.find_one(uid=attachment_uid)
if not att:
return JSONResponse({"error": "Attachment not found"}, status_code=404)
if att.get("resource_uid"):
resource_type = att["resource_type"]
if resource_type == "post":
post = get_table("posts").find_one(uid=att["resource_uid"])
if not post or post["user_uid"] != user["uid"]:
return JSONResponse({"error": "Not authorized"}, status_code=403)
elif resource_type == "comment":
comment = get_table("comments").find_one(uid=att["resource_uid"])
if not comment or comment["user_uid"] != user["uid"]:
return JSONResponse({"error": "Not authorized"}, status_code=403)
from devplacepy.database import _delete_attachment_file
_delete_attachment_file(att.get("storage_path", ""))
attachments.delete(id=att["id"])
logger.info(f"Attachment {attachment_uid} deleted by {user['username']}")
return JSONResponse({"status": "deleted"})