phase 15
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 11s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 8s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 8s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 18s
CI/CD Pipeline / Nix Flake Check (push) Successful in 43s
CI/CD Pipeline / CI Summary (push) Successful in 0s

This commit is contained in:
Danilo Reyes
2025-11-02 15:16:00 -06:00
parent c68a6a7d01
commit d4fbdf9273
9 changed files with 1024 additions and 19 deletions

128
backend/app/api/export.py Normal file
View File

@@ -0,0 +1,128 @@
"""Export API endpoints for downloading and exporting images."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.deps import get_current_user, get_db
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
from app.images.download import download_single_image
from app.images.export_composite import create_composite_export
from app.images.export_zip import create_zip_export
router = APIRouter(tags=["export"])
@router.get("/images/{image_id}/download")
async def download_image(
image_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> StreamingResponse:
"""
Download a single image.
Only the image owner can download it.
"""
# Verify image exists and user owns it
image = db.query(Image).filter(Image.id == image_id, Image.user_id == current_user.id).first()
if image is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found or access denied",
)
return await download_single_image(image.storage_path, image.filename)
@router.get("/boards/{board_id}/export/zip")
def export_board_zip(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> StreamingResponse:
"""
Export all images from a board as a ZIP file.
Only the board owner can export it.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
return create_zip_export(str(board_id), db)
@router.get("/boards/{board_id}/export/composite")
def export_board_composite(
board_id: UUID,
scale: float = Query(1.0, ge=0.5, le=4.0, description="Resolution scale (0.5x to 4x)"),
format: str = Query("PNG", regex="^(PNG|JPEG)$", description="Output format (PNG or JPEG)"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> StreamingResponse:
"""
Export board as a single composite image showing the layout.
Only the board owner can export it.
Args:
scale: Resolution multiplier (0.5x, 1x, 2x, 4x)
format: Output format (PNG or JPEG)
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
return create_composite_export(str(board_id), db, scale=scale, format=format)
@router.get("/boards/{board_id}/export/info")
def get_export_info(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
"""
Get information about board export (image count, estimated size).
Useful for showing progress estimates.
"""
# Verify board exists and user owns it
board = db.query(Board).filter(Board.id == board_id, Board.user_id == current_user.id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found or access denied",
)
# Count images and calculate estimated size
images = (
db.query(Image).join(BoardImage, BoardImage.image_id == Image.id).filter(BoardImage.board_id == board_id).all()
)
total_size = sum(img.file_size for img in images)
return {
"board_id": str(board_id),
"image_count": len(images),
"total_size_bytes": total_size,
"estimated_zip_size_bytes": int(total_size * 0.95), # ZIP usually has small overhead
}

View File

@@ -91,6 +91,27 @@ class StorageClient:
logger.error(f"Failed to download file {object_name}: {e}")
raise
def get_object(self, object_name: str) -> bytes | None:
"""Get object as bytes from MinIO.
Args:
object_name: S3 object name (path)
Returns:
bytes: File data or None if not found
Raises:
Exception: If download fails for reasons other than not found
"""
try:
file_data = self.download_file(object_name)
return file_data.read()
except ClientError as e:
if e.response["Error"]["Code"] == "404":
return None
logger.error(f"Failed to get object {object_name}: {e}")
raise
def delete_file(self, object_name: str) -> None:
"""Delete file from MinIO.

View File

@@ -0,0 +1,62 @@
"""Image download functionality."""
import io
from pathlib import Path
from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
from app.core.storage import storage_client
async def download_single_image(storage_path: str, filename: str) -> StreamingResponse:
"""
Download a single image from storage.
Args:
storage_path: Path to image in MinIO
filename: Original filename for download
Returns:
StreamingResponse with image data
Raises:
HTTPException: If image not found or download fails
"""
try:
# Get image from storage
image_data = storage_client.get_object(storage_path)
if image_data is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found in storage",
)
# Determine content type from file extension
extension = Path(filename).suffix.lower()
content_type_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
}
content_type = content_type_map.get(extension, "application/octet-stream")
# Return streaming response
return StreamingResponse(
io.BytesIO(image_data),
media_type=content_type,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Cache-Control": "no-cache",
},
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to download image: {str(e)}",
) from e

View File

@@ -0,0 +1,228 @@
"""Composite image generation for board export."""
import io
from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
from PIL import Image as PILImage
from sqlalchemy.orm import Session
from app.core.storage import storage_client
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
def create_composite_export(board_id: str, db: Session, scale: float = 1.0, format: str = "PNG") -> StreamingResponse:
"""
Create a composite image showing the entire board layout.
Args:
board_id: Board UUID
db: Database session
scale: Resolution multiplier (1x, 2x, 4x)
format: Output format (PNG or JPEG)
Returns:
StreamingResponse with composite image
Raises:
HTTPException: If export fails
"""
try:
# Get board
board = db.query(Board).filter(Board.id == board_id).first()
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Get all images for the board with positions
board_images = (
db.query(BoardImage, Image)
.join(Image, BoardImage.image_id == Image.id)
.filter(BoardImage.board_id == board_id)
.order_by(BoardImage.z_order)
.all()
)
if not board_images:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No images found for this board",
)
# Calculate canvas bounds
bounds = _calculate_canvas_bounds(board_images)
if not bounds:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unable to calculate canvas bounds",
)
min_x, min_y, max_x, max_y = bounds
# Calculate canvas size with padding
padding = 50
canvas_width = int((max_x - min_x + 2 * padding) * scale)
canvas_height = int((max_y - min_y + 2 * padding) * scale)
# Limit canvas size to prevent memory issues
max_dimension = 8192 # 8K resolution limit
if canvas_width > max_dimension or canvas_height > max_dimension:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Composite image too large (max {max_dimension}x{max_dimension})",
)
# Create blank canvas
if format.upper() == "JPEG":
canvas = PILImage.new("RGB", (canvas_width, canvas_height), color=(255, 255, 255))
else:
canvas = PILImage.new("RGBA", (canvas_width, canvas_height), color=(255, 255, 255, 255))
# Composite each image onto canvas
for board_image, image in board_images:
try:
# Get image from storage
image_data = storage_client.get_object(image.storage_path)
if not image_data:
continue
# Open image
pil_image = PILImage.open(io.BytesIO(image_data))
# Apply transformations
transformed_image = _apply_transformations(pil_image, board_image.transformations, scale)
# Calculate position on canvas
pos = board_image.position
x = int((pos["x"] - min_x + padding) * scale)
y = int((pos["y"] - min_y + padding) * scale)
# Paste onto canvas
if transformed_image.mode == "RGBA":
canvas.paste(transformed_image, (x, y), transformed_image)
else:
canvas.paste(transformed_image, (x, y))
except Exception as e:
# Log error but continue with other images
print(f"Warning: Failed to composite {image.filename}: {str(e)}")
continue
# Save to buffer
output = io.BytesIO()
if format.upper() == "JPEG":
canvas = canvas.convert("RGB")
canvas.save(output, format="JPEG", quality=95)
media_type = "image/jpeg"
extension = "jpg"
else:
canvas.save(output, format="PNG", optimize=True)
media_type = "image/png"
extension = "png"
output.seek(0)
# Return composite image
return StreamingResponse(
output,
media_type=media_type,
headers={
"Content-Disposition": f'attachment; filename="board_composite.{extension}"',
"Cache-Control": "no-cache",
},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create composite export: {str(e)}",
) from e
def _calculate_canvas_bounds(board_images) -> tuple[float, float, float, float] | None:
"""
Calculate the bounding box for all images.
Args:
board_images: List of (BoardImage, Image) tuples
Returns:
Tuple of (min_x, min_y, max_x, max_y) or None
"""
if not board_images:
return None
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
for board_image, image in board_images:
pos = board_image.position
transforms = board_image.transformations
x = pos["x"]
y = pos["y"]
width = image.width * transforms.get("scale", 1.0)
height = image.height * transforms.get("scale", 1.0)
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x + width)
max_y = max(max_y, y + height)
return (min_x, min_y, max_x, max_y)
def _apply_transformations(image: PILImage.Image, transformations: dict, scale: float) -> PILImage.Image:
"""
Apply transformations to an image.
Args:
image: PIL Image
transformations: Transformation dict
scale: Resolution multiplier
Returns:
Transformed PIL Image
"""
# Apply scale
img_scale = transformations.get("scale", 1.0) * scale
if img_scale != 1.0:
new_width = int(image.width * img_scale)
new_height = int(image.height * img_scale)
image = image.resize((new_width, new_height), PILImage.Resampling.LANCZOS)
# Apply rotation
rotation = transformations.get("rotation", 0)
if rotation != 0:
image = image.rotate(-rotation, expand=True, resample=PILImage.Resampling.BICUBIC)
# Apply flips
if transformations.get("flipped_h", False):
image = image.transpose(PILImage.Transpose.FLIP_LEFT_RIGHT)
if transformations.get("flipped_v", False):
image = image.transpose(PILImage.Transpose.FLIP_TOP_BOTTOM)
# Apply greyscale
if transformations.get("greyscale", False):
if image.mode == "RGBA":
# Preserve alpha channel
alpha = image.split()[-1]
image = image.convert("L").convert("RGBA")
image.putalpha(alpha)
else:
image = image.convert("L")
# Apply opacity
opacity = transformations.get("opacity", 1.0)
if opacity < 1.0 and image.mode in ("RGBA", "LA"):
alpha = image.split()[-1]
alpha = alpha.point(lambda p: int(p * opacity))
image.putalpha(alpha)
return image

View File

@@ -0,0 +1,103 @@
"""ZIP export functionality for multiple images."""
import io
import zipfile
from fastapi import HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.core.storage import storage_client
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
def create_zip_export(board_id: str, db: Session) -> StreamingResponse:
"""
Create a ZIP file containing all images from a board.
Args:
board_id: Board UUID
db: Database session
Returns:
StreamingResponse with ZIP file
Raises:
HTTPException: If export fails
"""
try:
# Get all images for the board
board_images = (
db.query(BoardImage, Image)
.join(Image, BoardImage.image_id == Image.id)
.filter(BoardImage.board_id == board_id)
.all()
)
if not board_images:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No images found for this board",
)
# Create ZIP file in memory
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
for _board_image, image in board_images:
try:
# Get image data from storage
image_data = storage_client.get_object(image.storage_path)
if image_data:
# Add to ZIP with sanitized filename
safe_filename = _sanitize_filename(image.filename)
zip_file.writestr(safe_filename, image_data)
except Exception as e:
# Log error but continue with other images
print(f"Warning: Failed to add {image.filename} to ZIP: {str(e)}")
continue
# Reset buffer position
zip_buffer.seek(0)
# Return ZIP file
return StreamingResponse(
zip_buffer,
media_type="application/zip",
headers={
"Content-Disposition": 'attachment; filename="board_export.zip"',
"Cache-Control": "no-cache",
},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create ZIP export: {str(e)}",
) from e
def _sanitize_filename(filename: str) -> str:
"""
Sanitize filename for safe inclusion in ZIP.
Args:
filename: Original filename
Returns:
Sanitized filename
"""
# Remove any path separators and dangerous characters
safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._- ")
sanitized = "".join(c if c in safe_chars else "_" for c in filename)
# Ensure it's not empty and doesn't start with a dot
if not sanitized or sanitized[0] == ".":
sanitized = "file_" + sanitized
return sanitized

View File

@@ -5,7 +5,7 @@ import logging
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api import auth, boards, groups, images, sharing
from app.api import auth, boards, export, groups, images, sharing
from app.core.config import settings
from app.core.errors import WebRefException
from app.core.logging import setup_logging
@@ -87,6 +87,7 @@ app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(sharing.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(export.router, prefix=f"{settings.API_V1_PREFIX}")
@app.on_event("startup")