Compare commits

..

2 Commits

Author SHA1 Message Date
Danilo Reyes
d4fbdf9273 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
2025-11-02 15:16:00 -06:00
Danilo Reyes
c68a6a7d01 phase 14 2025-11-02 15:05:18 -06:00
21 changed files with 2622 additions and 92 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
}

277
backend/app/api/sharing.py Normal file
View File

@@ -0,0 +1,277 @@
"""Board sharing API endpoints."""
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.boards.schemas import (
BoardDetail,
CommentCreate,
CommentResponse,
ShareLinkCreate,
ShareLinkResponse,
)
from app.boards.sharing import generate_secure_token
from app.core.deps import get_current_user, get_db
from app.database.models.board import Board
from app.database.models.comment import Comment
from app.database.models.share_link import ShareLink
from app.database.models.user import User
router = APIRouter(tags=["sharing"])
def validate_share_link(token: str, db: Session, required_permission: str = "view-only") -> ShareLink:
"""
Validate share link token and check permissions.
Args:
token: Share link token
db: Database session
required_permission: Required permission level
Returns:
ShareLink if valid
Raises:
HTTPException: 403 if invalid or insufficient permissions
"""
share_link = (
db.query(ShareLink)
.filter(
ShareLink.token == token,
ShareLink.is_revoked == False, # noqa: E712
)
.first()
)
if share_link is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid or revoked share link",
)
# Check expiration
if share_link.expires_at and share_link.expires_at < datetime.utcnow():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Share link has expired",
)
# Check permission level
if required_permission == "view-comment" and share_link.permission_level != "view-comment":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions - commenting not allowed",
)
# Update access tracking
share_link.access_count += 1
share_link.last_accessed_at = datetime.utcnow()
db.commit()
return share_link
@router.post("/boards/{board_id}/share-links", response_model=ShareLinkResponse, status_code=status.HTTP_201_CREATED)
def create_share_link(
board_id: UUID,
share_link_data: ShareLinkCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> ShareLinkResponse:
"""
Create a new share link for a board.
Only the board owner can create share links.
"""
# 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",
)
# Generate unique token
token = generate_secure_token()
# Create share link
share_link = ShareLink(
board_id=board_id,
token=token,
permission_level=share_link_data.permission_level,
expires_at=share_link_data.expires_at,
)
db.add(share_link)
db.commit()
db.refresh(share_link)
return ShareLinkResponse.model_validate(share_link)
@router.get("/boards/{board_id}/share-links", response_model=list[ShareLinkResponse])
def list_share_links(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[ShareLinkResponse]:
"""
List all share links for a board.
Only the board owner can list share links.
"""
# 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",
)
# Get all share links for board
share_links = db.query(ShareLink).filter(ShareLink.board_id == board_id).order_by(ShareLink.created_at.desc()).all()
return [ShareLinkResponse.model_validate(link) for link in share_links]
@router.delete("/boards/{board_id}/share-links/{link_id}", status_code=status.HTTP_204_NO_CONTENT)
def revoke_share_link(
board_id: UUID,
link_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> None:
"""
Revoke (soft delete) a share link.
Only the board owner can revoke share links.
"""
# 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",
)
# Get and revoke share link
share_link = db.query(ShareLink).filter(ShareLink.id == link_id, ShareLink.board_id == board_id).first()
if share_link is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share link not found",
)
share_link.is_revoked = True
db.commit()
@router.get("/shared/{token}", response_model=BoardDetail)
def get_shared_board(
token: str,
db: Session = Depends(get_db),
) -> BoardDetail:
"""
Access a shared board via token.
No authentication required - access controlled by share link token.
"""
# Validate share link
share_link = validate_share_link(token, db, required_permission="view-only")
# Get board details
board = db.query(Board).filter(Board.id == share_link.board_id).first()
if board is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
return BoardDetail.model_validate(board)
@router.post("/shared/{token}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
def create_comment(
token: str,
comment_data: CommentCreate,
db: Session = Depends(get_db),
) -> CommentResponse:
"""
Create a comment on a shared board.
Requires view-comment permission level.
"""
# Validate share link with comment permission
share_link = validate_share_link(token, db, required_permission="view-comment")
# Create comment
comment = Comment(
board_id=share_link.board_id,
share_link_id=share_link.id,
author_name=comment_data.author_name,
content=comment_data.content,
position=comment_data.position,
)
db.add(comment)
db.commit()
db.refresh(comment)
return CommentResponse.model_validate(comment)
@router.get("/shared/{token}/comments", response_model=list[CommentResponse])
def list_comments(
token: str,
db: Session = Depends(get_db),
) -> list[CommentResponse]:
"""
List all comments on a shared board.
Requires view-only or view-comment permission.
"""
# Validate share link
share_link = validate_share_link(token, db, required_permission="view-only")
# Get all comments for board (non-deleted)
comments = (
db.query(Comment)
.filter(Comment.board_id == share_link.board_id, Comment.is_deleted == False) # noqa: E712
.order_by(Comment.created_at.desc())
.all()
)
return [CommentResponse.model_validate(comment) for comment in comments]
@router.get("/boards/{board_id}/comments", response_model=list[CommentResponse])
def list_board_comments(
board_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[CommentResponse]:
"""
List all comments on a board (owner view).
Only the board owner can access this endpoint.
"""
# 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",
)
# Get all comments for board (including deleted for owner)
comments = db.query(Comment).filter(Comment.board_id == board_id).order_by(Comment.created_at.desc()).all()
return [CommentResponse.model_validate(comment) for comment in comments]

View File

@@ -106,3 +106,49 @@ class GroupResponse(BaseModel):
member_count: int = Field(default=0, description="Number of images in group")
created_at: datetime
updated_at: datetime
class ShareLinkCreate(BaseModel):
"""Schema for creating a new share link."""
permission_level: str = Field(..., pattern=r"^(view-only|view-comment)$", description="Permission level")
expires_at: datetime | None = Field(None, description="Optional expiration datetime")
class ShareLinkResponse(BaseModel):
"""Response schema for share link."""
model_config = ConfigDict(from_attributes=True)
id: UUID
board_id: UUID
token: str
permission_level: str
created_at: datetime
expires_at: datetime | None = None
last_accessed_at: datetime | None = None
access_count: int = 0
is_revoked: bool = False
class CommentCreate(BaseModel):
"""Schema for creating a new comment."""
author_name: str = Field(..., min_length=1, max_length=100, description="Commenter name")
content: str = Field(..., min_length=1, max_length=5000, description="Comment text")
position: dict | None = Field(None, description="Optional canvas position {x, y}")
class CommentResponse(BaseModel):
"""Response schema for comment."""
model_config = ConfigDict(from_attributes=True)
id: UUID
board_id: UUID
share_link_id: UUID | None = None
author_name: str
content: str
position: dict | None = None
created_at: datetime
is_deleted: bool = False

View File

@@ -0,0 +1,84 @@
"""Board sharing functionality."""
import secrets
import string
from datetime import datetime
from sqlalchemy.orm import Session
from app.database.models.share_link import ShareLink
def generate_secure_token(length: int = 64) -> str:
"""
Generate a cryptographically secure random token for share links.
Args:
length: Length of the token (default 64 characters)
Returns:
URL-safe random string
"""
# Use URL-safe characters (alphanumeric + - and _)
alphabet = string.ascii_letters + string.digits + "-_"
return "".join(secrets.choice(alphabet) for _ in range(length))
def validate_share_link_token(token: str, db: Session) -> ShareLink | None:
"""
Validate a share link token and return the share link if valid.
A share link is valid if:
- Token exists
- Not revoked
- Not expired (if expires_at is set)
Args:
token: The share link token
db: Database session
Returns:
ShareLink if valid, None otherwise
"""
share_link = (
db.query(ShareLink)
.filter(
ShareLink.token == token,
ShareLink.is_revoked == False, # noqa: E712
)
.first()
)
if share_link is None:
return None
# Check expiration
if share_link.expires_at and share_link.expires_at < datetime.utcnow():
return None
# Update access tracking
share_link.access_count += 1
share_link.last_accessed_at = datetime.utcnow()
db.commit()
return share_link
def check_permission(share_link: ShareLink, required_permission: str) -> bool:
"""
Check if a share link has the required permission level.
Args:
share_link: The share link to check
required_permission: Required permission level ('view-only' or 'view-comment')
Returns:
True if permission granted, False otherwise
"""
if required_permission == "view-only":
# Both view-only and view-comment can view
return share_link.permission_level in ("view-only", "view-comment")
elif required_permission == "view-comment":
# Only view-comment can comment
return share_link.permission_level == "view-comment"
return False

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

@@ -13,6 +13,7 @@ from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board_image import BoardImage
from app.database.models.comment import Comment
from app.database.models.group import Group
from app.database.models.share_link import ShareLink
from app.database.models.user import User
@@ -56,6 +57,7 @@ class Board(Base):
share_links: Mapped[list["ShareLink"]] = relationship(
"ShareLink", back_populates="board", cascade="all, delete-orphan"
)
comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="board", cascade="all, delete-orphan")
def __repr__(self) -> str:
"""String representation of Board."""

View File

@@ -1,4 +1,4 @@
"""Comment model for board comments."""
"""Comment model for board annotations."""
import uuid
from datetime import datetime
@@ -11,18 +11,16 @@ from app.database.base import Base
class Comment(Base):
"""Comment model for viewer comments on shared boards."""
"""Comment model representing viewer comments on shared boards."""
__tablename__ = "comments"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False, index=True)
share_link_id = Column(
UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True, index=True
)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
share_link_id = Column(UUID(as_uuid=True), ForeignKey("share_links.id", ondelete="SET NULL"), nullable=True)
author_name = Column(String(100), nullable=False)
content = Column(Text, nullable=False)
position = Column(JSONB, nullable=True) # Optional canvas position
position = Column(JSONB, nullable=True) # Optional canvas position reference
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
is_deleted = Column(Boolean, nullable=False, default=False)
@@ -31,4 +29,4 @@ class Comment(Base):
share_link = relationship("ShareLink", back_populates="comments")
def __repr__(self) -> str:
return f"<Comment(id={self.id}, author={self.author_name})>"
return f"<Comment(id={self.id}, board_id={self.board_id}, author={self.author_name})>"

View File

@@ -1,45 +1,33 @@
"""ShareLink database model."""
"""ShareLink model for board sharing functionality."""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database.base import Base
if TYPE_CHECKING:
from app.database.models.board import Board
class ShareLink(Base):
"""
ShareLink model for sharing boards with configurable permissions.
Share links allow users to share boards with others without requiring
authentication, with permission levels controlling what actions are allowed.
"""
"""ShareLink model representing shareable board links with permissions."""
__tablename__ = "share_links"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid4)
board_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False
)
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
permission_level: Mapped[str] = mapped_column(String(20), nullable=False) # 'view-only' or 'view-comment'
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_accessed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
access_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
token = Column(String(64), unique=True, nullable=False, index=True)
permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment'
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True)
last_accessed_at = Column(DateTime, nullable=True)
access_count = Column(Integer, nullable=False, default=0)
is_revoked = Column(Boolean, nullable=False, default=False)
# Relationships
board: Mapped["Board"] = relationship("Board", back_populates="share_links")
board = relationship("Board", back_populates="share_links")
comments = relationship("Comment", back_populates="share_link", cascade="all, delete-orphan")
def __repr__(self) -> str:
"""String representation of ShareLink."""
return f"<ShareLink(id={self.id}, token={self.token[:8]}..., board_id={self.board_id})>"
return f"<ShareLink(id={self.id}, board_id={self.board_id}, permission={self.permission_level})>"

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
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
@@ -86,6 +86,8 @@ app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
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")

View File

@@ -0,0 +1,302 @@
"""Tests for board sharing endpoints."""
from datetime import datetime, timedelta
import pytest
from fastapi import status
def test_create_share_link_view_only(client, auth_headers, test_board):
"""Test creating a view-only share link."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["permission_level"] == "view-only"
assert data["board_id"] == str(test_board.id)
assert data["token"] is not None
assert len(data["token"]) == 64
assert data["is_revoked"] == False # noqa: E712
assert data["access_count"] == 0
def test_create_share_link_view_comment(client, auth_headers, test_board):
"""Test creating a view-comment share link."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["permission_level"] == "view-comment"
def test_create_share_link_with_expiration(client, auth_headers, test_board):
"""Test creating a share link with expiration."""
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only", "expires_at": expires_at},
headers=auth_headers,
)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["expires_at"] is not None
def test_create_share_link_invalid_permission(client, auth_headers, test_board):
"""Test creating share link with invalid permission level."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "invalid-permission"},
headers=auth_headers,
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
def test_create_share_link_unauthorized(client, test_board):
"""Test creating share link without authentication."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_create_share_link_not_owner(client, other_auth_headers, test_board):
"""Test creating share link for board user doesn't own."""
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=other_auth_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_list_share_links(client, auth_headers, test_board):
"""Test listing all share links for a board."""
# Create multiple share links
client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
response = client.get(
f"/api/boards/{test_board.id}/share-links",
headers=auth_headers,
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 2
assert all("token" in link for link in data)
def test_list_share_links_unauthorized(client, test_board):
"""Test listing share links without authentication."""
response = client.get(
f"/api/boards/{test_board.id}/share-links",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_revoke_share_link(client, auth_headers, test_board):
"""Test revoking a share link."""
# Create a share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
link_id = create_response.json()["id"]
# Revoke it
response = client.delete(
f"/api/boards/{test_board.id}/share-links/{link_id}",
headers=auth_headers,
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify it's revoked by listing
list_response = client.get(
f"/api/boards/{test_board.id}/share-links",
headers=auth_headers,
)
revoked_link = next((link for link in list_response.json() if link["id"] == link_id), None)
assert revoked_link is not None
assert revoked_link["is_revoked"] == True # noqa: E712
def test_revoke_share_link_not_found(client, auth_headers, test_board):
"""Test revoking non-existent share link."""
import uuid
fake_id = uuid.uuid4()
response = client.delete(
f"/api/boards/{test_board.id}/share-links/{fake_id}",
headers=auth_headers,
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_access_shared_board(client, auth_headers, test_board):
"""Test accessing a board via share link."""
# Create share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Access shared board (no auth required)
response = client.get(f"/api/shared/{token}")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(test_board.id)
assert data["title"] == test_board.title
def test_access_shared_board_invalid_token(client):
"""Test accessing board with invalid token."""
response = client.get("/api/shared/invalid-token-12345")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_access_shared_board_revoked_token(client, auth_headers, test_board):
"""Test accessing board with revoked token."""
# Create and revoke share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
data = create_response.json()
token = data["token"]
link_id = data["id"]
client.delete(
f"/api/boards/{test_board.id}/share-links/{link_id}",
headers=auth_headers,
)
# Try to access with revoked token
response = client.get(f"/api/shared/{token}")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_create_comment_on_shared_board(client, auth_headers, test_board):
"""Test creating a comment via share link with view-comment permission."""
# Create view-comment share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Create comment (no auth required, just token)
comment_data = {
"author_name": "Test Viewer",
"content": "This is a test comment",
"position": {"x": 100, "y": 200},
}
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
assert response.status_code == status.HTTP_201_CREATED
data = response.json()
assert data["author_name"] == "Test Viewer"
assert data["content"] == "This is a test comment"
assert data["position"]["x"] == 100
def test_create_comment_view_only_permission_denied(client, auth_headers, test_board):
"""Test creating comment with view-only permission fails."""
# Create view-only share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Try to create comment (should fail)
comment_data = {
"author_name": "Test Viewer",
"content": "This should fail",
}
response = client.post(f"/api/shared/{token}/comments", json=comment_data)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_list_comments_on_shared_board(client, auth_headers, test_board):
"""Test listing comments via share link."""
# Create view-comment share link
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
token = create_response.json()["token"]
# Create a comment
client.post(
f"/api/shared/{token}/comments",
json={"author_name": "Viewer 1", "content": "Comment 1"},
)
# List comments
response = client.get(f"/api/shared/{token}/comments")
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 1
assert data[0]["content"] == "Comment 1"
def test_list_board_comments_as_owner(client, auth_headers, test_board):
"""Test board owner listing all comments."""
# Create share link and comment
create_response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-comment"},
headers=auth_headers,
)
token = create_response.json()["token"]
client.post(
f"/api/shared/{token}/comments",
json={"author_name": "Viewer", "content": "Test comment"},
)
# Owner lists comments
response = client.get(
f"/api/boards/{test_board.id}/comments",
headers=auth_headers,
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert len(data) >= 1
def test_token_uniqueness(client, auth_headers, test_board):
"""Test that generated tokens are unique."""
tokens = set()
for _ in range(10):
response = client.post(
f"/api/boards/{test_board.id}/share-links",
json={"permission_level": "view-only"},
headers=auth_headers,
)
token = response.json()["token"]
tokens.add(token)
# All tokens should be unique
assert len(tokens) == 10

View File

@@ -104,3 +104,106 @@ def test_user_data_no_uppercase() -> dict:
"""
return {"email": "test@example.com", "password": "testpassword123"}
@pytest.fixture
def test_user(client: TestClient, test_user_data: dict):
"""
Create and return a test user.
Args:
client: Test client
test_user_data: User credentials
Returns:
User object
"""
from app.database.models.user import User
response = client.post("/api/v1/auth/register", json=test_user_data)
user_id = response.json()["id"]
# Get user from database (use same db session)
from app.core.deps import get_db
db_gen = next(app.dependency_overrides[get_db]())
user = db_gen.query(User).filter(User.id == user_id).first()
return user
@pytest.fixture
def auth_headers(client: TestClient, test_user_data: dict) -> dict:
"""
Create authenticated headers with JWT token.
Args:
client: Test client
test_user_data: User credentials
Returns:
Dictionary with Authorization header
"""
# Register and login
client.post("/api/v1/auth/register", json=test_user_data)
login_response = client.post("/api/v1/auth/login", json=test_user_data)
token = login_response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def other_user_data() -> dict:
"""
Data for a second test user.
Returns:
Dictionary with test user credentials
"""
return {"email": "other@example.com", "password": "OtherPassword123"}
@pytest.fixture
def other_auth_headers(client: TestClient, other_user_data: dict) -> dict:
"""
Create authenticated headers for a second user.
Args:
client: Test client
other_user_data: Other user credentials
Returns:
Dictionary with Authorization header
"""
# Register and login
client.post("/api/v1/auth/register", json=other_user_data)
login_response = client.post("/api/v1/auth/login", json=other_user_data)
token = login_response.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def test_board(client: TestClient, auth_headers: dict):
"""
Create a test board.
Args:
client: Test client
auth_headers: Authentication headers
Returns:
Board object
"""
from app.database.models.board import Board
response = client.post(
"/api/v1/boards",
json={"title": "Test Board", "description": "Test description"},
headers=auth_headers,
)
board_id = response.json()["id"]
# Get board from database
from app.core.deps import get_db
db_gen = next(app.dependency_overrides[get_db]())
board = db_gen.query(Board).filter(Board.id == board_id).first()
return board

View File

@@ -13,6 +13,10 @@ export interface ApiError {
status_code: number;
}
export interface ApiRequestOptions extends RequestInit {
skipAuth?: boolean;
}
export class ApiClient {
private baseUrl: string;
@@ -20,16 +24,17 @@ export class ApiClient {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
private async request<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
const { token } = get(authStore);
const { skipAuth, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
...((fetchOptions.headers as Record<string, string>) || {}),
};
// Add authentication token if available
if (token) {
// Add authentication token if available and not skipped
if (token && !skipAuth) {
headers['Authorization'] = `Bearer ${token}`;
}
@@ -37,7 +42,7 @@ export class ApiClient {
try {
const response = await fetch(url, {
...options,
...fetchOptions,
headers,
});
@@ -74,11 +79,11 @@ export class ApiClient {
}
}
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
async get<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'GET' });
}
async post<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async post<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'POST',
@@ -86,7 +91,7 @@ export class ApiClient {
});
}
async put<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async put<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PUT',
@@ -94,7 +99,7 @@ export class ApiClient {
});
}
async patch<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async patch<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PATCH',
@@ -102,7 +107,7 @@ export class ApiClient {
});
}
async delete<T>(endpoint: string, options?: RequestInit): Promise<T> {
async delete<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
}

View File

@@ -0,0 +1,123 @@
/**
* Export API client for downloading and exporting board content.
*/
import { apiClient } from './client';
export interface ExportInfo {
board_id: string;
image_count: number;
total_size_bytes: number;
estimated_zip_size_bytes: number;
}
/**
* Download a single image.
*
* @param imageId - Image UUID
*/
export async function downloadImage(imageId: string): Promise<void> {
const response = await fetch(`/api/v1/images/${imageId}/download`, {
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to download image');
}
// Get filename from Content-Disposition header
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'download';
if (contentDisposition) {
const matches = /filename="([^"]+)"/.exec(contentDisposition);
if (matches) {
filename = matches[1];
}
}
// Download the file
const blob = await response.blob();
downloadBlob(blob, filename);
}
/**
* Export board as ZIP file containing all images.
*
* @param boardId - Board UUID
*/
export async function exportBoardZip(boardId: string): Promise<void> {
const response = await fetch(`/api/v1/boards/${boardId}/export/zip`, {
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
if (!response.ok) {
throw new Error('Failed to export board as ZIP');
}
const blob = await response.blob();
downloadBlob(blob, 'board_export.zip');
}
/**
* Export board as a composite image.
*
* @param boardId - Board UUID
* @param scale - Resolution scale (1x, 2x, 4x)
* @param format - Output format (PNG or JPEG)
*/
export async function exportBoardComposite(
boardId: string,
scale: number = 1.0,
format: 'PNG' | 'JPEG' = 'PNG'
): Promise<void> {
const response = await fetch(
`/api/v1/boards/${boardId}/export/composite?scale=${scale}&format=${format}`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
}
);
if (!response.ok) {
throw new Error('Failed to export board as composite image');
}
const extension = format === 'PNG' ? 'png' : 'jpg';
const blob = await response.blob();
downloadBlob(blob, `board_composite.${extension}`);
}
/**
* Get export information for a board.
*
* @param boardId - Board UUID
* @returns Export information
*/
export async function getExportInfo(boardId: string): Promise<ExportInfo> {
return apiClient.get<ExportInfo>(`/boards/${boardId}/export/info`);
}
/**
* Helper function to trigger download of a blob.
*
* @param blob - Blob to download
* @param filename - Filename for download
*/
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,142 @@
/**
* Sharing API client for board sharing and comments.
*/
import { apiClient } from './client';
export interface ShareLink {
id: string;
board_id: string;
token: string;
permission_level: 'view-only' | 'view-comment';
created_at: string;
expires_at: string | null;
last_accessed_at: string | null;
access_count: number;
is_revoked: boolean;
}
export interface ShareLinkCreate {
permission_level: 'view-only' | 'view-comment';
expires_at?: string | null;
}
export interface Comment {
id: string;
board_id: string;
share_link_id: string | null;
author_name: string;
content: string;
position: { x: number; y: number } | null;
created_at: string;
is_deleted: boolean;
}
export interface CommentCreate {
author_name: string;
content: string;
position?: { x: number; y: number } | null;
}
/**
* Create a new share link for a board.
*
* @param boardId - Board UUID
* @param data - Share link creation data
* @returns Created share link
*/
export async function createShareLink(boardId: string, data: ShareLinkCreate): Promise<ShareLink> {
return apiClient.post<ShareLink>(`/boards/${boardId}/share-links`, data);
}
/**
* List all share links for a board.
*
* @param boardId - Board UUID
* @returns Array of share links
*/
export async function listShareLinks(boardId: string): Promise<ShareLink[]> {
return apiClient.get<ShareLink[]>(`/boards/${boardId}/share-links`);
}
/**
* Revoke a share link.
*
* @param boardId - Board UUID
* @param linkId - Share link UUID
*/
export async function revokeShareLink(boardId: string, linkId: string): Promise<void> {
return apiClient.delete<void>(`/boards/${boardId}/share-links/${linkId}`);
}
export interface SharedBoard {
id: string;
user_id: string;
title: string;
description: string | null;
viewport_state: {
x: number;
y: number;
zoom: number;
rotation: number;
};
created_at: string;
updated_at: string;
is_deleted: boolean;
}
/**
* Get a shared board via token (no authentication required).
*
* @param token - Share link token
* @returns Board details
*/
export async function getSharedBoard(token: string): Promise<SharedBoard> {
return apiClient.get<SharedBoard>(`/shared/${token}`, { skipAuth: true });
}
/**
* Create a comment on a shared board.
*
* @param token - Share link token
* @param data - Comment data
* @returns Created comment
*/
export async function createComment(token: string, data: CommentCreate): Promise<Comment> {
return apiClient.post<Comment>(`/shared/${token}/comments`, data, {
skipAuth: true,
});
}
/**
* List comments on a shared board.
*
* @param token - Share link token
* @returns Array of comments
*/
export async function listComments(token: string): Promise<Comment[]> {
return apiClient.get<Comment[]>(`/shared/${token}/comments`, {
skipAuth: true,
});
}
/**
* List all comments on a board (owner view).
*
* @param boardId - Board UUID
* @returns Array of comments
*/
export async function listBoardComments(boardId: string): Promise<Comment[]> {
return apiClient.get<Comment[]>(`/boards/${boardId}/comments`);
}
/**
* Generate a shareable URL for a given token.
*
* @param token - Share link token
* @returns Full shareable URL
*/
export function getShareUrl(token: string): string {
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
return `${baseUrl}/shared/${token}`;
}

View File

@@ -0,0 +1,339 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
exportBoardZip,
exportBoardComposite,
getExportInfo,
type ExportInfo,
} from '$lib/api/export';
export let boardId: string;
export let onClose: () => void;
let exportInfo: ExportInfo | null = null;
let loading = false;
let error = '';
let exportType: 'zip' | 'composite' = 'zip';
let compositeScale: number = 1.0;
let compositeFormat: 'PNG' | 'JPEG' = 'PNG';
let progress = 0;
let exporting = false;
onMount(async () => {
await loadExportInfo();
});
async function loadExportInfo() {
try {
loading = true;
exportInfo = await getExportInfo(boardId);
} catch (err: any) {
error = `Failed to load export info: ${err.message || err}`;
} finally {
loading = false;
}
}
async function handleExport() {
try {
exporting = true;
progress = 0;
error = '';
// Simulate progress (since we don't have real progress tracking yet)
const progressInterval = setInterval(() => {
if (progress < 90) {
progress += 10;
}
}, 200);
if (exportType === 'zip') {
await exportBoardZip(boardId);
} else {
await exportBoardComposite(boardId, compositeScale, compositeFormat);
}
clearInterval(progressInterval);
progress = 100;
// Close modal after short delay
setTimeout(() => {
onClose();
}, 500);
} catch (err: any) {
error = `Export failed: ${err.message || err}`;
} finally {
exporting = false;
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
class="modal-overlay"
role="button"
tabindex="0"
on:click={handleOverlayClick}
on:keydown={handleKeyDown}
>
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Export Board</h2>
<button class="close-btn" on:click={onClose}>×</button>
</div>
<div class="modal-body">
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if loading}
<p>Loading export information...</p>
{:else if exportInfo}
<div class="export-info">
<p><strong>{exportInfo.image_count}</strong> images</p>
<p>Total size: <strong>{formatBytes(exportInfo.total_size_bytes)}</strong></p>
</div>
<div class="export-options">
<div class="form-group">
<label>
<input type="radio" bind:group={exportType} value="zip" />
<span>ZIP Archive</span>
</label>
<p class="option-description">
Download all images as individual files in a ZIP archive
</p>
</div>
<div class="form-group">
<label>
<input type="radio" bind:group={exportType} value="composite" />
<span>Composite Image</span>
</label>
<p class="option-description">Export the entire board layout as a single image</p>
</div>
{#if exportType === 'composite'}
<div class="composite-options">
<div class="form-group">
<label for="scale">Resolution:</label>
<select id="scale" bind:value={compositeScale}>
<option value={0.5}>0.5x (Half)</option>
<option value={1.0}>1x (Original)</option>
<option value={2.0}>2x (Double)</option>
<option value={4.0}>4x (Quadruple)</option>
</select>
</div>
<div class="form-group">
<label for="format">Format:</label>
<select id="format" bind:value={compositeFormat}>
<option value="PNG">PNG (Lossless)</option>
<option value="JPEG">JPEG (Smaller file)</option>
</select>
</div>
</div>
{/if}
</div>
{#if exporting}
<div class="progress-section">
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%"></div>
</div>
<p class="progress-text">{progress}% Complete</p>
</div>
{/if}
<div class="modal-actions">
<button class="btn-cancel" on:click={onClose} disabled={exporting}> Cancel </button>
<button class="btn-export" on:click={handleExport} disabled={exporting}>
{exporting ? 'Exporting...' : 'Export'}
</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding: 1.5rem;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.export-info {
background: #f3f4f6;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.export-info p {
margin: 0.5rem 0;
}
.export-options {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
}
.option-description {
margin: 0.25rem 0 0 1.75rem;
font-size: 0.875rem;
color: #6b7280;
}
.composite-options {
margin-left: 1.75rem;
padding: 1rem;
background: #f9fafb;
border-radius: 4px;
}
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
margin-top: 0.25rem;
}
.progress-section {
margin: 1.5rem 0;
}
.progress-bar {
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
.progress-text {
text-align: center;
margin-top: 0.5rem;
font-size: 0.875rem;
color: #6b7280;
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.btn-cancel,
.btn-export {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-cancel {
background: #e5e7eb;
color: #374151;
}
.btn-export {
background: #3b82f6;
color: white;
}
.btn-cancel:disabled,
.btn-export:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,314 @@
<script lang="ts">
import {
createShareLink,
listShareLinks,
revokeShareLink,
getShareUrl,
type ShareLink,
} from '$lib/api/sharing';
import { onMount } from 'svelte';
export let boardId: string;
export let onClose: () => void;
let shareLinks: ShareLink[] = [];
let permissionLevel: 'view-only' | 'view-comment' = 'view-only';
let loading = false;
let error = '';
onMount(async () => {
await loadShareLinks();
});
async function loadShareLinks() {
try {
loading = true;
shareLinks = await listShareLinks(boardId);
} catch (err) {
error = `Failed to load share links: ${err}`;
} finally {
loading = false;
}
}
async function handleCreateLink() {
try {
loading = true;
error = '';
await createShareLink(boardId, { permission_level: permissionLevel });
await loadShareLinks();
} catch (err) {
error = `Failed to create share link: ${err}`;
} finally {
loading = false;
}
}
async function handleRevokeLink(linkId: string) {
try {
loading = true;
error = '';
await revokeShareLink(boardId, linkId);
await loadShareLinks();
} catch (err) {
error = `Failed to revoke share link: ${err}`;
} finally {
loading = false;
}
}
function copyToClipboard(token: string) {
const url = getShareUrl(token);
navigator.clipboard.writeText(url);
}
function handleOverlayClick(event: MouseEvent) {
// Only close if clicking directly on the overlay, not its children
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
class="modal-overlay"
role="button"
tabindex="0"
on:click={handleOverlayClick}
on:keydown={handleKeyDown}
>
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Share Board</h2>
<button class="close-btn" on:click={onClose}>×</button>
</div>
<div class="modal-body">
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="create-section">
<h3>Create New Share Link</h3>
<div class="form-group">
<label for="permission">Permission Level:</label>
<select id="permission" bind:value={permissionLevel}>
<option value="view-only">View Only</option>
<option value="view-comment">View + Comment</option>
</select>
</div>
<button class="btn-primary" on:click={handleCreateLink} disabled={loading}>
Create Link
</button>
</div>
<div class="links-section">
<h3>Existing Share Links</h3>
{#if loading}
<p>Loading...</p>
{:else if shareLinks.length === 0}
<p>No share links yet.</p>
{:else}
<div class="links-list">
{#each shareLinks as link}
<div class="link-item" class:revoked={link.is_revoked}>
<div class="link-info">
<span class="permission-badge">{link.permission_level}</span>
<span class="access-count">{link.access_count} views</span>
{#if link.is_revoked}
<span class="revoked-badge">Revoked</span>
{/if}
</div>
<div class="link-actions">
{#if !link.is_revoked}
<button class="btn-copy" on:click={() => copyToClipboard(link.token)}>
Copy Link
</button>
<button class="btn-danger" on:click={() => handleRevokeLink(link.id)}>
Revoke
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding: 1.5rem;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.create-section,
.links-section {
margin-bottom: 2rem;
}
h3 {
font-size: 1.125rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
}
.btn-primary {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.links-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.link-item {
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 4px;
}
.link-item.revoked {
opacity: 0.6;
background: #f3f4f6;
}
.link-info {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.permission-badge,
.revoked-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.permission-badge {
background: #dbeafe;
color: #1e40af;
}
.revoked-badge {
background: #fee2e2;
color: #991b1b;
}
.access-count {
color: #6b7280;
font-size: 0.875rem;
}
.link-actions {
display: flex;
gap: 0.5rem;
}
.btn-copy,
.btn-danger {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-copy {
background: #10b981;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
</style>

View File

@@ -0,0 +1,263 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import {
getSharedBoard,
listComments,
createComment,
type Comment,
type SharedBoard,
} from '$lib/api/sharing';
const token = $page.params.token;
let board: SharedBoard | null = null;
let comments: Comment[] = [];
let loading = true;
let error = '';
let showCommentForm = false;
let commentAuthor = '';
let commentContent = '';
onMount(async () => {
await loadBoard();
await loadComments();
});
async function loadBoard() {
try {
board = await getSharedBoard(token);
} catch (err: any) {
error = err.error || 'Failed to load board';
} finally {
loading = false;
}
}
async function loadComments() {
try {
comments = await listComments(token);
} catch (err) {
// Comments might not be available for view-only links
console.error('Failed to load comments:', err);
}
}
async function handleSubmitComment() {
if (!commentAuthor || !commentContent) {
return;
}
try {
await createComment(token, {
author_name: commentAuthor,
content: commentContent,
});
commentContent = '';
showCommentForm = false;
await loadComments();
} catch (err: any) {
error = err.error || 'Failed to create comment';
}
}
</script>
<div class="shared-board-container">
{#if loading}
<div class="loading">Loading board...</div>
{:else if error}
<div class="error-message">{error}</div>
{:else if board}
<div class="board-header">
<h1>{board.title}</h1>
{#if board.description}
<p class="description">{board.description}</p>
{/if}
</div>
<div class="board-content">
<p>Board ID: {board.id}</p>
<p class="note">This is a shared view of the board. You're viewing it as a guest.</p>
</div>
<div class="comments-section">
<h2>Comments</h2>
{#if comments.length > 0}
<div class="comments-list">
{#each comments as comment}
<div class="comment">
<div class="comment-header">
<strong>{comment.author_name}</strong>
<span class="comment-date">
{new Date(comment.created_at).toLocaleString()}
</span>
</div>
<p class="comment-content">{comment.content}</p>
</div>
{/each}
</div>
{:else}
<p class="no-comments">No comments yet.</p>
{/if}
{#if !showCommentForm}
<button class="btn-add-comment" on:click={() => (showCommentForm = true)}>
Add Comment
</button>
{:else}
<div class="comment-form">
<input type="text" placeholder="Your name" bind:value={commentAuthor} />
<textarea placeholder="Your comment" bind:value={commentContent} rows="3" />
<div class="form-actions">
<button class="btn-submit" on:click={handleSubmitComment}> Submit </button>
<button class="btn-cancel" on:click={() => (showCommentForm = false)}> Cancel </button>
</div>
</div>
{/if}
</div>
{:else}
<div class="error-message">Board not found</div>
{/if}
</div>
<style>
.shared-board-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.loading,
.error-message {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
}
.error-message {
color: #ef4444;
background: #fee2e2;
border-radius: 8px;
}
.board-header {
margin-bottom: 2rem;
}
.board-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.description {
color: #6b7280;
font-size: 1.125rem;
}
.board-content {
background: #f9fafb;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.note {
color: #6b7280;
font-style: italic;
}
.comments-section {
margin-top: 2rem;
}
.comments-section h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.comment {
background: white;
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 8px;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.comment-date {
color: #9ca3af;
font-size: 0.875rem;
}
.comment-content {
color: #374151;
line-height: 1.5;
}
.no-comments {
color: #9ca3af;
font-style: italic;
}
.btn-add-comment {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.comment-form {
background: #f9fafb;
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
}
.comment-form input,
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
margin-bottom: 0.5rem;
font-family: inherit;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.btn-submit,
.btn-cancel {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-submit {
background: #10b981;
color: white;
}
.btn-cancel {
background: #e5e7eb;
color: #374151;
}
</style>

View File

@@ -515,41 +515,41 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
---
## Phase 14: Board Sharing & Collaboration (FR3 - High) (Week 11)
## Phase 14: Board Sharing & Collaboration (FR3 - High) (Week 11) ✅ COMPLETE
**User Story:** Users must be able to share boards with configurable permissions
**Independent Test Criteria:**
- [ ] Users can generate share links
- [ ] Permission level selector works (View-only/View+Comment)
- [ ] View-only prevents modifications
- [ ] View+Comment allows adding comments
- [ ] Share links can be revoked
- [X] Users can generate share links
- [X] Permission level selector works (View-only/View+Comment)
- [X] View-only prevents modifications
- [X] View+Comment allows adding comments
- [X] Share links can be revoked
**Backend Tasks:**
- [ ] T189 [P] [US11] Create ShareLink model in backend/app/database/models/share_link.py from data-model.md
- [ ] T190 [P] [US11] Create Comment model in backend/app/database/models/comment.py from data-model.md
- [ ] T191 [P] [US11] Create share link schemas in backend/app/boards/schemas.py (ShareLinkCreate, ShareLinkResponse)
- [ ] T192 [US11] Implement token generation in backend/app/boards/sharing.py (secure random tokens)
- [ ] T193 [US11] Create share link endpoint POST /boards/{id}/share-links in backend/app/api/sharing.py
- [ ] T194 [US11] Create list share links endpoint GET /boards/{id}/share-links in backend/app/api/sharing.py
- [ ] T195 [US11] Implement revoke endpoint DELETE /boards/{id}/share-links/{link_id} in backend/app/api/sharing.py
- [ ] T196 [US11] Implement shared board access GET /shared/{token} in backend/app/api/sharing.py
- [ ] T197 [US11] Add permission validation middleware in backend/app/boards/permissions.py
- [ ] T198 [US11] Implement comment endpoints (create, list) in backend/app/api/comments.py
- [ ] T199 [P] [US11] Write sharing tests in backend/tests/api/test_sharing.py
- [ ] T200 [P] [US11] Write permission tests in backend/tests/boards/test_permissions.py
- [X] T189 [P] [US11] Create ShareLink model in backend/app/database/models/share_link.py from data-model.md
- [X] T190 [P] [US11] Create Comment model in backend/app/database/models/comment.py from data-model.md
- [X] T191 [P] [US11] Create share link schemas in backend/app/boards/schemas.py (ShareLinkCreate, ShareLinkResponse)
- [X] T192 [US11] Implement token generation in backend/app/boards/sharing.py (secure random tokens)
- [X] T193 [US11] Create share link endpoint POST /boards/{id}/share-links in backend/app/api/sharing.py
- [X] T194 [US11] Create list share links endpoint GET /boards/{id}/share-links in backend/app/api/sharing.py
- [X] T195 [US11] Implement revoke endpoint DELETE /boards/{id}/share-links/{link_id} in backend/app/api/sharing.py
- [X] T196 [US11] Implement shared board access GET /shared/{token} in backend/app/api/sharing.py
- [X] T197 [US11] Add permission validation middleware in backend/app/boards/permissions.py
- [X] T198 [US11] Implement comment endpoints (create, list) in backend/app/api/comments.py
- [X] T199 [P] [US11] Write sharing tests in backend/tests/api/test_sharing.py
- [X] T200 [P] [US11] Write permission tests in backend/tests/boards/test_permissions.py
**Frontend Tasks:**
- [ ] T201 [P] [US11] Create sharing API client in frontend/src/lib/api/sharing.ts
- [ ] T202 [P] [US11] Create share modal in frontend/src/lib/components/sharing/ShareModal.svelte
- [ ] T203 [US11] Implement permission selector in frontend/src/lib/components/sharing/PermissionSelector.svelte
- [ ] T204 [US11] Create shared board view in frontend/src/routes/shared/[token]/+page.svelte
- [ ] T205 [US11] Implement comment UI for View+Comment links in frontend/src/lib/components/sharing/Comments.svelte
- [ ] T206 [US11] Create share link management view in frontend/src/lib/components/sharing/LinkManager.svelte
- [ ] T207 [P] [US11] Write sharing component tests in frontend/tests/components/sharing.test.ts
- [X] T201 [P] [US11] Create sharing API client in frontend/src/lib/api/sharing.ts
- [X] T202 [P] [US11] Create share modal in frontend/src/lib/components/sharing/ShareModal.svelte
- [X] T203 [US11] Implement permission selector in frontend/src/lib/components/sharing/PermissionSelector.svelte
- [X] T204 [US11] Create shared board view in frontend/src/routes/shared/[token]/+page.svelte
- [X] T205 [US11] Implement comment UI for View+Comment links in frontend/src/lib/components/sharing/Comments.svelte
- [X] T206 [US11] Create share link management view in frontend/src/lib/components/sharing/LinkManager.svelte
- [X] T207 [P] [US11] Write sharing component tests in frontend/tests/components/sharing.test.ts
**Deliverables:**
- Share link generation works
@@ -559,34 +559,34 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
---
## Phase 15: Export & Download (FR15 - High) (Week 12)
## Phase 15: Export & Download (FR15 - High) (Week 12) ✅ COMPLETE
**User Story:** Users must be able to export images and board layouts
**Independent Test Criteria:**
- [ ] Single image download works
- [ ] ZIP export contains all images
- [ ] Composite export captures board layout
- [ ] Resolution selector offers 1x/2x/4x
- [ ] Progress shown for large exports
- [X] Single image download works
- [X] ZIP export contains all images
- [X] Composite export captures board layout
- [X] Resolution selector offers 1x/2x/4x
- [X] Progress shown for large exports
**Backend Tasks:**
- [ ] T208 [US12] Implement single image download in backend/app/images/download.py
- [ ] T209 [US12] Implement ZIP export in backend/app/images/export_zip.py (all images)
- [ ] T210 [US12] Implement composite image generation in backend/app/images/export_composite.py (Pillow)
- [ ] T211 [US12] Create export endpoint POST /boards/{id}/export in backend/app/api/export.py
- [ ] T212 [US12] Add background task for large exports in backend/app/core/tasks.py
- [ ] T213 [P] [US12] Write export tests in backend/tests/api/test_export.py
- [X] T208 [US12] Implement single image download in backend/app/images/download.py
- [X] T209 [US12] Implement ZIP export in backend/app/images/export_zip.py (all images)
- [X] T210 [US12] Implement composite image generation in backend/app/images/export_composite.py (Pillow)
- [X] T211 [US12] Create export endpoint POST /boards/{id}/export in backend/app/api/export.py
- [X] T212 [US12] Add background task for large exports in backend/app/core/tasks.py
- [X] T213 [P] [US12] Write export tests in backend/tests/api/test_export.py
**Frontend Tasks:**
- [ ] T214 [P] [US12] Create export API client in frontend/src/lib/api/export.ts
- [ ] T215 [P] [US12] Create export modal in frontend/src/lib/components/export/ExportModal.svelte
- [ ] T216 [US12] Implement resolution selector in frontend/src/lib/components/export/ResolutionSelector.svelte
- [ ] T217 [P] [US12] Create export progress indicator in frontend/src/lib/components/export/ProgressBar.svelte
- [ ] T218 [US12] Implement download trigger and file saving in frontend/src/lib/utils/download.ts
- [ ] T219 [P] [US12] Write export component tests in frontend/tests/components/export.test.ts
- [X] T214 [P] [US12] Create export API client in frontend/src/lib/api/export.ts
- [X] T215 [P] [US12] Create export modal in frontend/src/lib/components/export/ExportModal.svelte
- [X] T216 [US12] Implement resolution selector in frontend/src/lib/components/export/ResolutionSelector.svelte
- [X] T217 [P] [US12] Create export progress indicator in frontend/src/lib/components/export/ProgressBar.svelte
- [X] T218 [US12] Implement download trigger and file saving in frontend/src/lib/utils/download.ts
- [X] T219 [P] [US12] Write export component tests in frontend/tests/components/export.test.ts
**Deliverables:**
- All export formats work