|
1 | 1 | """File management API endpoints backed by ABI ObjectStorageService.""" |
2 | 2 |
|
3 | 3 | from datetime import datetime |
4 | | -from pathlib import PurePosixPath |
| 4 | +from pathlib import Path, PurePosixPath |
| 5 | +import shutil |
| 6 | +import subprocess |
| 7 | +import tempfile |
5 | 8 |
|
6 | | -from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile |
| 9 | +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile |
7 | 10 | from naas_abi.apps.nexus.apps.api.app.api.endpoints.auth import get_current_user_required |
8 | 11 | from naas_abi_core.services.object_storage.ObjectStoragePort import Exceptions |
9 | 12 | from naas_abi_core.services.object_storage.ObjectStorageService import ObjectStorageService |
10 | 13 | from pydantic import BaseModel, Field |
11 | 14 |
|
12 | 15 | router = APIRouter(dependencies=[Depends(get_current_user_required)]) |
13 | 16 |
|
14 | | -OBJECT_STORAGE_PREFIX = "nexus/files" |
| 17 | +# Empty prefix means: browse the object storage base_prefix as-is. |
| 18 | +# With current config this is `abi/datastore`, so users can navigate |
| 19 | +# `external/...`, `nexus/...`, etc. directly from the Files UI. |
| 20 | +OBJECT_STORAGE_PREFIX = "" |
15 | 21 | FOLDER_MARKER = ".nexus_folder" |
16 | 22 | MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50MB |
17 | 23 |
|
@@ -142,7 +148,7 @@ def _list_directory(storage: ObjectStorageService, path: str) -> list[str]: |
142 | 148 |
|
143 | 149 | children: set[str] = set() |
144 | 150 | for raw_path in raw_paths: |
145 | | - relative = _relative_from_storage_path(raw_path) |
| 151 | + relative = _relative_from_storage_path(raw_path).strip("/") |
146 | 152 | if not relative: |
147 | 153 | continue |
148 | 154 | if relative == FOLDER_MARKER or relative.endswith(f"/{FOLDER_MARKER}"): |
@@ -240,6 +246,13 @@ async def list_files(request: Request, path: str = Query("", description="Direct |
240 | 246 | ".txt": "text/plain", |
241 | 247 | ".yaml": "text/yaml", |
242 | 248 | ".yml": "text/yaml", |
| 249 | + ".pdf": "application/pdf", |
| 250 | + ".png": "image/png", |
| 251 | + ".jpg": "image/jpeg", |
| 252 | + ".jpeg": "image/jpeg", |
| 253 | + ".gif": "image/gif", |
| 254 | + ".webp": "image/webp", |
| 255 | + ".svg": "image/svg+xml", |
243 | 256 | } |
244 | 257 | content_type = content_types.get(ext, "application/octet-stream") |
245 | 258 | except Exceptions.ObjectNotFound: |
@@ -396,6 +409,107 @@ async def upload_file( |
396 | 409 | # ============================================================================= |
397 | 410 |
|
398 | 411 |
|
| 412 | +@router.get("/preview/pdf/{path:path}") |
| 413 | +async def preview_file_as_pdf(request: Request, path: str): |
| 414 | + """Preview a presentation file as PDF (requires LibreOffice).""" |
| 415 | + storage = get_object_storage(request) |
| 416 | + normalized_path = normalize_relative_path(path) |
| 417 | + ext = PurePosixPath(normalized_path).suffix.lower() |
| 418 | + if ext not in {".ppt", ".pptx"}: |
| 419 | + raise HTTPException(status_code=400, detail="Only PPT/PPTX preview is supported") |
| 420 | + if _is_directory(storage, normalized_path): |
| 421 | + raise HTTPException(status_code=400, detail="Cannot preview a directory") |
| 422 | + |
| 423 | + try: |
| 424 | + content_bytes = _read_bytes(storage, normalized_path) |
| 425 | + except Exceptions.ObjectNotFound as exc: |
| 426 | + raise HTTPException(status_code=404, detail="File not found") from exc |
| 427 | + |
| 428 | + soffice = shutil.which("soffice") or shutil.which("libreoffice") |
| 429 | + if not soffice: |
| 430 | + raise HTTPException( |
| 431 | + status_code=501, |
| 432 | + detail="PPTX preview is unavailable: LibreOffice is not installed on the API service.", |
| 433 | + ) |
| 434 | + |
| 435 | + with tempfile.TemporaryDirectory(prefix="nexus-ppt-preview-") as tmp_dir: |
| 436 | + input_path = Path(tmp_dir) / PurePosixPath(normalized_path).name |
| 437 | + output_name = f"{PurePosixPath(normalized_path).stem}.pdf" |
| 438 | + output_path = Path(tmp_dir) / output_name |
| 439 | + input_path.write_bytes(content_bytes) |
| 440 | + |
| 441 | + result = subprocess.run( |
| 442 | + [ |
| 443 | + soffice, |
| 444 | + "--headless", |
| 445 | + "--convert-to", |
| 446 | + "pdf", |
| 447 | + "--outdir", |
| 448 | + str(Path(tmp_dir)), |
| 449 | + str(input_path), |
| 450 | + ], |
| 451 | + capture_output=True, |
| 452 | + text=True, |
| 453 | + timeout=60, |
| 454 | + check=False, |
| 455 | + ) |
| 456 | + if result.returncode != 0 or not output_path.exists(): |
| 457 | + raise HTTPException( |
| 458 | + status_code=500, |
| 459 | + detail="Failed to convert presentation to PDF preview.", |
| 460 | + ) |
| 461 | + |
| 462 | + pdf_bytes = output_path.read_bytes() |
| 463 | + |
| 464 | + return Response( |
| 465 | + content=pdf_bytes, |
| 466 | + media_type="application/pdf", |
| 467 | + headers={"Content-Disposition": f'inline; filename="{output_name}"'}, |
| 468 | + ) |
| 469 | + |
| 470 | + |
| 471 | +@router.get("/raw/{path:path}") |
| 472 | +async def read_file_raw(request: Request, path: str): |
| 473 | + """Read raw file bytes (for binary previews/downloads).""" |
| 474 | + storage = get_object_storage(request) |
| 475 | + normalized_path = normalize_relative_path(path) |
| 476 | + |
| 477 | + if _is_directory(storage, normalized_path): |
| 478 | + raise HTTPException(status_code=400, detail="Cannot read a directory") |
| 479 | + |
| 480 | + try: |
| 481 | + content_bytes = _read_bytes(storage, normalized_path) |
| 482 | + except Exceptions.ObjectNotFound as exc: |
| 483 | + raise HTTPException(status_code=404, detail="File not found") from exc |
| 484 | + |
| 485 | + ext = PurePosixPath(normalized_path).suffix.lower() |
| 486 | + content_types = { |
| 487 | + ".py": "text/x-python", |
| 488 | + ".js": "text/javascript", |
| 489 | + ".ts": "text/typescript", |
| 490 | + ".json": "application/json", |
| 491 | + ".md": "text/markdown", |
| 492 | + ".txt": "text/plain", |
| 493 | + ".yaml": "text/yaml", |
| 494 | + ".yml": "text/yaml", |
| 495 | + ".pdf": "application/pdf", |
| 496 | + ".png": "image/png", |
| 497 | + ".jpg": "image/jpeg", |
| 498 | + ".jpeg": "image/jpeg", |
| 499 | + ".gif": "image/gif", |
| 500 | + ".webp": "image/webp", |
| 501 | + ".svg": "image/svg+xml", |
| 502 | + } |
| 503 | + content_type = content_types.get(ext, "application/octet-stream") |
| 504 | + filename = PurePosixPath(normalized_path).name |
| 505 | + |
| 506 | + return Response( |
| 507 | + content=content_bytes, |
| 508 | + media_type=content_type, |
| 509 | + headers={"Content-Disposition": f'inline; filename="{filename}"'}, |
| 510 | + ) |
| 511 | + |
| 512 | + |
399 | 513 | @router.get("/{path:path}", response_model=FileContent) |
400 | 514 | async def read_file(request: Request, path: str): |
401 | 515 | """Read file content.""" |
|
0 commit comments