# retoor import logging from pathlib import Path from typing import Annotated from fastapi import Depends, APIRouter, Request from fastapi.responses import JSONResponse from devplacepy.database import ( get_table, get_int_setting, get_user_attachments, get_user_attachment, ) from devplacepy.models import UploadUrlForm, AttachmentRenameForm from devplacepy.utils import require_user_api, is_admin, track_action from devplacepy.attachments import ( store_attachment, store_attachment_from_url, soft_delete_attachment, rename_attachment, is_extension_allowed, RemoteFetchError, ) from devplacepy.schemas import UploadItemOut, UploadsListOut from urllib.parse import urlparse from devplacepy.services.audit import record as audit from devplacepy.dependencies import json_or_form logger = logging.getLogger(__name__) router = APIRouter() @router.post("/upload") async def upload_file(request: Request): user = require_user_api(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) ext = Path(file.filename).suffix.lower() if not is_extension_allowed(ext): return JSONResponse( {"error": f"File type '{ext}' not allowed"}, status_code=415 ) 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) result = store_attachment(content, file.filename, user["uid"]) if result is None: max_size_mb = get_int_setting("max_upload_size_mb", 10) return JSONResponse( {"error": f"File exceeds {max_size_mb}MB limit"}, status_code=413 ) logger.info( f"File uploaded: {file.filename} ({len(content)} bytes) -> {result['url']}" ) audit.record( request, "attachment.upload", user=user, target_type="attachment", target_uid=result.get("uid"), target_label=file.filename, metadata={"size": len(content), "mime": result.get("mime_type")}, summary=f"{user['username']} uploaded attachment {file.filename}", links=[audit.attachment_link(result.get("uid"), file.filename)], ) track_action(user["uid"], "upload") return JSONResponse(result, status_code=201) @router.post("/upload-url") async def upload_from_url(request: Request, data: Annotated[UploadUrlForm, Depends(json_or_form(UploadUrlForm))]): user = require_user_api(request) try: result = await store_attachment_from_url(data.url, user["uid"], data.filename) except RemoteFetchError as exc: return JSONResponse({"error": exc.message}, status_code=exc.status) logger.info( f"URL attached: {data.url} ({result['file_size']} bytes) -> {result['url']}" ) audit.record( request, "attachment.upload_url", user=user, target_type="attachment", target_uid=result.get("uid"), target_label=result.get("filename"), metadata={"source_host": urlparse(data.url).hostname, "size": result.get("file_size"), "mime": result.get("mime_type")}, summary=f"{user['username']} attached a remote file from {urlparse(data.url).hostname}", links=[audit.attachment_link(result.get("uid"), result.get("filename"))], ) return JSONResponse(result, status_code=201) def _linked_filter(linked): if linked is None: return None return linked.strip().lower() in ("1", "true", "yes", "linked") @router.get("") async def list_attachments( request: Request, page: int = 1, linked: str | None = None ): user = require_user_api(request) items, pagination = get_user_attachments( user["uid"], page=page, linked=_linked_filter(linked) ) payload = { "attachments": items, "pagination": pagination, "total": pagination.get("total", len(items)), } return JSONResponse(UploadsListOut.model_validate(payload).model_dump(mode="json")) @router.get("/{attachment_uid}") async def get_attachment_route(request: Request, attachment_uid: str): user = require_user_api(request) item = get_user_attachment(attachment_uid) if not item: return JSONResponse({"error": "Attachment not found"}, status_code=404) if item.get("user_uid") and item["user_uid"] != user["uid"] and not is_admin(user): return JSONResponse({"error": "Not authorized"}, status_code=403) return JSONResponse(UploadItemOut.model_validate(item).model_dump(mode="json")) @router.patch("/{attachment_uid}") async def rename_attachment_route( request: Request, attachment_uid: str, data: Annotated[ AttachmentRenameForm, Depends(json_or_form(AttachmentRenameForm)) ], ): user = require_user_api(request) att = get_table("attachments").find_one(uid=attachment_uid, deleted_at=None) if not att: return JSONResponse({"error": "Attachment not found"}, status_code=404) if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user): return JSONResponse({"error": "Not authorized"}, status_code=403) old_name = att.get("original_filename") new_name = rename_attachment(attachment_uid, data.filename) if new_name is None: return JSONResponse({"error": "Invalid filename"}, status_code=400) logger.info(f"Attachment {attachment_uid} renamed to {new_name} by {user['username']}") audit.record( request, "attachment.rename", user=user, target_type="attachment", target_uid=attachment_uid, target_label=new_name, old_value=old_name, new_value=new_name, summary=f"{user['username']} renamed attachment to {new_name}", links=[audit.attachment_link(attachment_uid, new_name)], ) item = get_user_attachment(attachment_uid) return JSONResponse(UploadItemOut.model_validate(item).model_dump(mode="json")) @router.delete("/delete/{attachment_uid}") async def delete_attachment_route(request: Request, attachment_uid: str): user = require_user_api(request) att = get_table("attachments").find_one(uid=attachment_uid, deleted_at=None) if not att: return JSONResponse({"error": "Attachment not found"}, status_code=404) if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user): return JSONResponse({"error": "Not authorized"}, status_code=403) soft_delete_attachment(attachment_uid, deleted_by=user["uid"]) logger.info(f"Attachment {attachment_uid} soft-deleted by {user['username']}") audit.record( request, "attachment.delete", user=user, target_type="attachment", target_uid=attachment_uid, target_label=att.get("filename"), summary=f"{user['username']} deleted attachment {att.get('filename') or attachment_uid}", links=[audit.attachment_link(attachment_uid, att.get("filename"))], ) return JSONResponse({"status": "deleted"})