2026-06-12 01:58:46 +02:00
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
2026-05-12 15:07:34 +02:00
|
|
|
import logging
|
|
|
|
|
from pathlib import Path
|
2026-06-11 00:17:25 +02:00
|
|
|
from typing import Annotated
|
2026-06-17 19:10:52 +02:00
|
|
|
from fastapi import Depends, APIRouter, Request
|
2026-05-12 15:07:34 +02:00
|
|
|
from fastapi.responses import JSONResponse
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
from devplacepy.database import get_table, get_int_setting
|
2026-06-11 00:17:25 +02:00
|
|
|
from devplacepy.models import UploadUrlForm
|
2026-06-16 05:32:19 +02:00
|
|
|
from devplacepy.utils import require_user_api, is_admin, track_action
|
2026-06-09 18:48:08 +02:00
|
|
|
from devplacepy.attachments import (
|
|
|
|
|
store_attachment,
|
2026-06-11 00:17:25 +02:00
|
|
|
store_attachment_from_url,
|
2026-06-12 00:40:27 +02:00
|
|
|
soft_delete_attachment,
|
2026-06-09 18:48:08 +02:00
|
|
|
is_extension_allowed,
|
2026-06-11 00:17:25 +02:00
|
|
|
RemoteFetchError,
|
2026-06-09 18:48:08 +02:00
|
|
|
)
|
2026-06-11 22:28:17 +02:00
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
from devplacepy.services.audit import record as audit
|
2026-06-17 19:10:52 +02:00
|
|
|
from devplacepy.dependencies import json_or_form
|
2026-05-12 15:07:34 +02:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
@router.post("/upload")
|
|
|
|
|
async def upload_file(request: Request):
|
2026-05-23 08:34:13 +02:00
|
|
|
user = require_user_api(request)
|
2026-05-12 15:07:34 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-05-23 01:50:31 +02:00
|
|
|
ext = Path(file.filename).suffix.lower()
|
feat: add project file system with CRUD, upload, inline editing, and video attachment support
- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
2026-06-08 22:51:09 +02:00
|
|
|
if not is_extension_allowed(ext):
|
2026-06-09 18:48:08 +02:00
|
|
|
return JSONResponse(
|
|
|
|
|
{"error": f"File type '{ext}' not allowed"}, status_code=415
|
|
|
|
|
)
|
2026-05-12 15:07:34 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-05-13 21:17:57 +02:00
|
|
|
result = store_attachment(content, file.filename, user["uid"])
|
|
|
|
|
if result is None:
|
2026-05-23 06:55:11 +02:00
|
|
|
max_size_mb = get_int_setting("max_upload_size_mb", 10)
|
2026-06-09 18:48:08 +02:00
|
|
|
return JSONResponse(
|
|
|
|
|
{"error": f"File exceeds {max_size_mb}MB limit"}, status_code=413
|
|
|
|
|
)
|
2026-05-12 15:07:34 +02:00
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
logger.info(
|
|
|
|
|
f"File uploaded: {file.filename} ({len(content)} bytes) -> {result['url']}"
|
|
|
|
|
)
|
2026-06-11 22:28:17 +02:00
|
|
|
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)],
|
|
|
|
|
)
|
2026-06-16 05:32:19 +02:00
|
|
|
track_action(user["uid"], "upload")
|
2026-05-13 21:17:57 +02:00
|
|
|
return JSONResponse(result, status_code=201)
|
2026-05-12 15:07:34 +02:00
|
|
|
|
2026-06-11 00:17:25 +02:00
|
|
|
@router.post("/upload-url")
|
2026-06-17 19:10:52 +02:00
|
|
|
async def upload_from_url(request: Request, data: Annotated[UploadUrlForm, Depends(json_or_form(UploadUrlForm))]):
|
2026-06-11 00:17:25 +02:00
|
|
|
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']}"
|
|
|
|
|
)
|
2026-06-11 22:28:17 +02:00
|
|
|
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"))],
|
|
|
|
|
)
|
2026-06-11 00:17:25 +02:00
|
|
|
return JSONResponse(result, status_code=201)
|
|
|
|
|
|
2026-05-12 15:07:34 +02:00
|
|
|
@router.delete("/delete/{attachment_uid}")
|
2026-05-13 21:17:57 +02:00
|
|
|
async def delete_attachment_route(request: Request, attachment_uid: str):
|
2026-05-23 08:34:13 +02:00
|
|
|
user = require_user_api(request)
|
2026-06-12 00:40:27 +02:00
|
|
|
att = get_table("attachments").find_one(uid=attachment_uid, deleted_at=None)
|
2026-05-12 15:07:34 +02:00
|
|
|
if not att:
|
|
|
|
|
return JSONResponse({"error": "Attachment not found"}, status_code=404)
|
2026-06-12 00:40:27 +02:00
|
|
|
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
|
2026-05-13 21:17:57 +02:00
|
|
|
return JSONResponse({"error": "Not authorized"}, status_code=403)
|
2026-06-12 00:40:27 +02:00
|
|
|
soft_delete_attachment(attachment_uid, deleted_by=user["uid"])
|
|
|
|
|
logger.info(f"Attachment {attachment_uid} soft-deleted by {user['username']}")
|
2026-06-11 22:28:17 +02:00
|
|
|
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"))],
|
|
|
|
|
)
|
2026-05-12 15:07:34 +02:00
|
|
|
return JSONResponse({"status": "deleted"})
|