117 lines
4.4 KiB
Python
117 lines
4.4 KiB
Python
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"})
|