Files
devplacepy/devplacepy/routers/uploads.py
T

117 lines
4.4 KiB
Python
Raw Normal View History

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"})