Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ dependencies = [

# Security: pin transitive deps to fix Dependabot alerts
"pillow==12.3.0",
# Reads .xlsx workbooks into rows for the UI's spreadsheet preview
# (apis/app_api/files/sheet_preview.py). MIT, pure Python, one
# dependency (et-xmlfile). Already the library our own
# create_excel_spreadsheet tool drives inside Code Interpreter, and
# the one the RAG ingestion image uses via docling — this pin brings
# it into app-api's own closure, which it was not in before.
"openpyxl==3.1.5",
"cryptography==50.0.1",
"python-multipart==0.0.31",
"aiohttp==3.14.3",
Expand Down
60 changes: 60 additions & 0 deletions backend/src/apis/app_api/files/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
PresignResponse,
CompleteUploadResponse,
PreviewUrlResponse,
SheetPreviewResponse,
TextSnippetResponse,
ThumbnailResponse,
FileListResponse,
Expand All @@ -34,6 +35,7 @@
FileNotFoundError,
FileUploadError,
)
from .sheet_preview import WorkbookTooLargeError, WorkbookUnreadableError
from .thumbnails import ThumbnailRenderError, ThumbnailUnsupportedError

from apis.shared.security.log_sanitize import scrub_log
Expand Down Expand Up @@ -227,6 +229,64 @@ async def get_text_snippet(
)


@router.get("/{upload_id}/sheet-preview", response_model=SheetPreviewResponse)
async def get_sheet_preview(
upload_id: str,
user: User = Depends(get_current_user_from_session),
service: FileUploadService = Depends(get_file_upload_service),
):
"""
Read an .xlsx workbook into rows for the UI's data grid.

The other previews (.docx, .pptx, .csv) hand the browser a presigned
URL and parse the bytes client-side. Spreadsheets cannot: the npm
build of SheetJS is frozen on a release with unfixed advisories, and
ExcelJS raises on any workbook holding a native chart — which is what
`create_excel_spreadsheet` produces. So the workbook is read here and
only values cross the wire.

Values only. No fills, fonts, borders, merges or charts; download and
open the file for those.

Status codes:
- 200: Sheets read (possibly truncated — see `truncated` on each).
- 404: File not found, not owned by the caller, or not readable.
- 413: Workbook is past the reader's size cap.
- 415: MIME type is not a readable workbook (the UI should not have
offered a preview; `.xls` lands here).
- 422: File present but unreadable (corrupt, encrypted, not OOXML).
"""
try:
return await service.get_sheet_preview(user.user_id, upload_id)

except FileNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"File {upload_id} not found or not owned by you",
)

except ThumbnailUnsupportedError as e:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=str(e),
)

except WorkbookTooLargeError:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="This workbook is too large to preview. Download it to open in Excel.",
)

except WorkbookUnreadableError:
# Deliberately not echoing openpyxl's message: it names internal
# XML parts and tells the user nothing they can act on.
logger.warning("Workbook could not be parsed")
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="This workbook could not be read. It may be corrupt or password-protected.",
)


@router.get("/{upload_id}/thumbnail", response_model=ThumbnailResponse)
async def get_thumbnail(
upload_id: str,
Expand Down
79 changes: 79 additions & 0 deletions backend/src/apis/app_api/files/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
PresignResponse,
CompleteUploadResponse,
PreviewUrlResponse,
SheetPreviewResponse,
SHEET_PREVIEW_MIME_TYPES,
TextSnippetResponse,
ThumbnailResponse,
THUMBNAIL_SUPPORTED_MIME_TYPES,
Expand All @@ -35,6 +37,12 @@
is_presentation_file,
ALLOWED_MIME_TYPES,
)
from .sheet_preview import (
MAX_WORKBOOK_BYTES,
WorkbookTooLargeError,
WorkbookUnreadableError,
read_workbook_preview,
)
from .thumbnails import (
ThumbnailRenderer,
ThumbnailRenderError,
Expand Down Expand Up @@ -540,6 +548,77 @@ async def get_text_snippet(
mime_type=file_meta.mime_type,
)

# =========================================================================
# Spreadsheet preview
# =========================================================================

async def get_sheet_preview(
self, user_id: str, upload_id: str
) -> SheetPreviewResponse:
"""Read an .xlsx into rows the UI can draw in its data grid.

The workbook never reaches the browser. Unlike the `.docx`,
`.pptx` and `.csv` previews — which fetch the bytes through a
presigned URL and parse them client-side — there is no
client-side spreadsheet reader we are willing to ship, so the
parse happens here and only values cross the wire.

Args:
user_id: The owner's user ID
upload_id: The upload identifier

Returns:
SheetPreviewResponse with one entry per visible worksheet

Raises:
FileNotFoundError: not found, not owned, or not ready
ThumbnailUnsupportedError: MIME type is not a readable workbook
WorkbookTooLargeError: past the reader's byte cap
WorkbookUnreadableError: corrupt, encrypted, or not OOXML
"""
file_meta = await self.repository.get_file(user_id, upload_id)
if not file_meta:
raise FileNotFoundError(f"File {upload_id} not found")

if file_meta.status != FileStatus.READY:
raise FileNotFoundError(
f"File {upload_id} is not ready (status: {file_meta.status})"
)

if file_meta.mime_type not in SHEET_PREVIEW_MIME_TYPES:
raise ThumbnailUnsupportedError(
f"No spreadsheet reader for {file_meta.mime_type}"
)

# Checked before the download so an oversized workbook costs a
# metadata read rather than a transfer into memory.
if file_meta.size_bytes > MAX_WORKBOOK_BYTES:
raise WorkbookTooLargeError(file_meta.size_bytes)

try:
response = self._s3_client.get_object(
Bucket=self.bucket_name,
Key=file_meta.s3_key,
)
data = response["Body"].read()
except ClientError as e:
logger.warning(
f"Failed to read workbook {scrub_log(upload_id)}: {scrub_log(e)}"
)
raise FileNotFoundError(f"File {upload_id} could not be read")

# openpyxl is CPU-bound and blocking, so it runs off the event
# loop. A 20 MB workbook parses for long enough to stall every
# other request on this worker if it does not.
sheets = await asyncio.to_thread(read_workbook_preview, data)

return SheetPreviewResponse(
upload_id=upload_id,
filename=file_meta.filename,
sheets=sheets,
truncated=any(sheet.truncated for sheet in sheets),
)

# =========================================================================
# Thumbnails
# =========================================================================
Expand Down
Loading