diff --git a/.env.example b/.env.example index 57b22e0..210be31 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,16 @@ GRAFANA_PASSWORD=change-this-password-in-production CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0 +# MinIO Configuration (Object Storage) +# MinIO server stays hidden - Flask proxies all requests +# MINIO_ENDPOINT: Internal Docker network address (for server-to-server communication) +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_USE_SSL=false +# Note: Thumbnails are served via /api/files/:id/proxy-thumbnail (no auth) +# Full files require JWT authentication via /api/files/:id/download + # Optional: External Services # REDIS_URL=redis://localhost:6379/0 # SMTP_HOST=smtp.gmail.com diff --git a/.gitignore b/.gitignore index 90d5441..e9ea7da 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,6 @@ htmlcov/ celerybeat-schedule -backend/app/static \ No newline at end of file +backend/app/static + +.clinerules \ No newline at end of file diff --git a/Makefile b/Makefile index 2cd52fa..c87bc6d 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,10 @@ install: ## Install dependencies @echo "Installing frontend dependencies..." cd frontend && npm install +dev-backend-install: + @echo "Installing backend dependencies..." + . backend/venv/bin/activate && pip install -r backend/requirements/dev.txt + dev-services: ## Start development services (postgres & redis only) @echo "Starting development services (postgres & redis)..." docker compose -f docker-compose.dev.yml up -d @@ -62,6 +66,9 @@ restart: ## Restart all services logs: ## Show logs from all services docker compose logs -f +dev-logs: ## Show logs from all services + docker compose -f docker-compose.dev.yml logs + test: ## Run all tests @echo "Running backend tests..." cd backend && . venv/bin/activate && pytest diff --git a/backend/.env.example b/backend/.env.example index d0b6be8..6d8bed9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,3 +9,8 @@ TEST_DATABASE_URL=sqlite:///test.db # Celery Configuration CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_RESULT_BACKEND=redis://localhost:6379/0 + +# Minio configuration +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 3c726d0..e8bb272 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -40,6 +40,13 @@ def create_app(config_name=None): app, resources={r"/api/*": {"origins": app.config.get("CORS_ORIGINS", "*")}} ) + # Import storage extension + from app.services.storage.storage_extension import storage + + if not (app.config.get("TESTING") or app.config.get("USE_MOCK_STORAGE")): + # Initialize storage extension (MinIO) + storage.init_app(app) + # Initialize Celery from app.celery import init_celery diff --git a/backend/app/config.py b/backend/app/config.py index 7eb3744..0a2bd23 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -46,11 +46,31 @@ class DevelopmentConfig(Config): DEBUG = True SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] + # MinIO Configuration for Development + MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000") + MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin") + MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin") + MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "false").lower() == "true" + MINIO_IMAGES_BUCKET = "kanban-images" + MINIO_DOCUMENTS_BUCKET = "kanban-documents" + MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails" + MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024 + MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024 + ALLOWED_IMAGE_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/svg+xml", + "image/webp", + ] + ALLOWED_DOCUMENT_TYPES = ["application/pdf"] + class TestingConfig(Config): """Testing configuration""" TESTING = True + USE_MOCK_STORAGE = True # Use in-memory mock storage for tests SQLALCHEMY_DATABASE_URI = os.environ.get("TEST_DATABASE_URL") WTF_CSRF_ENABLED = False @@ -63,6 +83,31 @@ class TestingConfig(Config): "pool_pre_ping": True, # Verify connections before using } + # MinIO configuration (not used in tests due to USE_MOCK_STORAGE=True) + MINIO_ENDPOINT = "minio:9000" + MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin") + MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin") + MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "false").lower() == "true" + + # Bucket names (required for FileService even with mock storage) + MINIO_IMAGES_BUCKET = "kanban-images" + MINIO_DOCUMENTS_BUCKET = "kanban-documents" + MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails" + + # File upload limits + MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024 # 10MB + MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024 # 50MB + + # Allowed file types + ALLOWED_IMAGE_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/svg+xml", + "image/webp", + ] + ALLOWED_DOCUMENT_TYPES = ["application/pdf"] + class ProductionConfig(Config): """Production configuration""" @@ -75,6 +120,31 @@ class ProductionConfig(Config): SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" + # MinIO Configuration + MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "minio:9000") + MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "minioadmin") + MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "minioadmin") + MINIO_USE_SSL = os.environ.get("MINIO_USE_SSL", "true").lower() == "true" + + # MinIO Buckets + MINIO_IMAGES_BUCKET = "kanban-images" + MINIO_DOCUMENTS_BUCKET = "kanban-documents" + MINIO_THUMBNAILS_BUCKET = "kanban-thumbnails" + + # File Upload Limits (in bytes) + MAX_UPLOAD_SIZE_IMAGE = 10 * 1024 * 1024 # 10MB + MAX_UPLOAD_SIZE_DOCUMENT = 50 * 1024 * 1024 # 50MB + + # Allowed File Types + ALLOWED_IMAGE_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/svg+xml", + "image/webp", + ] + ALLOWED_DOCUMENT_TYPES = ["application/pdf"] + config_by_name = { "dev": DevelopmentConfig, diff --git a/backend/app/decorators.py b/backend/app/decorators.py index dc5543c..1cdc9ee 100644 --- a/backend/app/decorators.py +++ b/backend/app/decorators.py @@ -3,7 +3,8 @@ from functools import wraps from flask import abort, g from flask_jwt_extended import get_jwt_identity -from app.models import Board, Card, CheckItem, Checklist, Comment, List +from app.models import (Board, Card, CheckItem, Checklist, Comment, + FileAttachment, List) def get_current_user_id(): @@ -157,3 +158,166 @@ def load_comment_owned(f): return f(*args, **kwargs) return decorated_function + + +def load_file_owned(f): + """ + Loads a FileAttachment ensuring it belongs to the user. + Aborts with 404 if not found or not owned. + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + file_id = kwargs.get("file_id") + + # Filter by ID and user ID + attachment = FileAttachment.query.filter_by( + id=file_id, uploaded_by=user_id + ).first() + + if not attachment: + abort(404) + + kwargs["file"] = attachment + return f(*args, **kwargs) + + return decorated_function + + +def load_file_accessible(f): + """ + Loads a FileAttachment ensuring the user can access it. + User can access if: + 1. They uploaded the file, OR + 2. The file is attached to an entity they own (via Board ownership) + + Aborts with 404 if not found or not accessible. + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + file_id = kwargs.get("file_id") + + # Try to find file uploaded by user + attachment = FileAttachment.query.filter_by( + id=file_id, uploaded_by=user_id + ).first() + + # If not found, check if attached to a Card that belongs to user's board + if not attachment: + # For Card attachments + card_attachment = ( + FileAttachment.query.join( + Card, + (FileAttachment.attachable_type == "Card") + & (FileAttachment.attachable_id == Card.id), + ) + .join(Board) + .filter( + FileAttachment.id == file_id, + Board.user_id == user_id, + ) + .first() + ) + if card_attachment: + attachment = card_attachment + + # If still not found, check if attached + # to a Comment that belongs to user's board + if not attachment: + # For Comment attachments + comment_attachment = ( + FileAttachment.query.join( + Comment, + (FileAttachment.attachable_type == "Comment") + & (FileAttachment.attachable_id == Comment.id), + ) + .join(Card) + .join(Board) + .filter( + FileAttachment.id == file_id, + Board.user_id == user_id, + ) + .first() + ) + if comment_attachment: + attachment = comment_attachment + + if not attachment: + abort(404) + + kwargs["file"] = attachment + return f(*args, **kwargs) + + return decorated_function + + +def load_file_accessible_by_uuid(f): + """ + Loads a FileAttachment by UUID ensuring the user can access it. + User can access if: + 1. They uploaded the file, OR + 2. The file is attached to an entity they own (via Board ownership) + + Aborts with 404 if not found or not accessible. + """ + + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = get_current_user_id() + file_uuid = kwargs.get("file_uuid") + + # Try to find file uploaded by user + attachment = FileAttachment.query.filter_by( + uuid=file_uuid, uploaded_by=user_id + ).first() + + # If not found, check if attached to a Card that belongs to user's board + if not attachment: + # For Card attachments + card_attachment = ( + FileAttachment.query.join( + Card, + (FileAttachment.attachable_type == "Card") + & (FileAttachment.attachable_id == Card.id), + ) + .join(Board) + .filter( + FileAttachment.uuid == file_uuid, + Board.user_id == user_id, + ) + .first() + ) + if card_attachment: + attachment = card_attachment + + # If still not found, check if attached to a + # Comment that belongs to user's board + if not attachment: + # For Comment attachments + comment_attachment = ( + FileAttachment.query.join( + Comment, + (FileAttachment.attachable_type == "Comment") + & (FileAttachment.attachable_id == Comment.id), + ) + .join(Card) + .join(Board) + .filter( + FileAttachment.uuid == file_uuid, + Board.user_id == user_id, + ) + .first() + ) + if comment_attachment: + attachment = comment_attachment + + if not attachment: + abort(404) + + kwargs["file"] = attachment + return f(*args, **kwargs) + + return decorated_function diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 746547b..215755d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ from app.models.card_label import CardLabel from app.models.check_item import CheckItem from app.models.checklist import Checklist from app.models.comment import Comment +from app.models.file_attachment import FileAttachment from app.models.label import Label from app.models.list_model import List from app.models.order import Order, OrderItem @@ -23,4 +24,5 @@ __all__ = [ "Checklist", "CheckItem", "Comment", + "FileAttachment", ] diff --git a/backend/app/models/card.py b/backend/app/models/card.py index 0cd2f9a..cbdb2bb 100644 --- a/backend/app/models/card.py +++ b/backend/app/models/card.py @@ -57,6 +57,14 @@ class Card(db.Model): comments = db.relationship( "Comment", backref="card", cascade="all, delete-orphan", lazy="dynamic" ) + attachments = db.relationship( + "FileAttachment", + foreign_keys="FileAttachment.attachable_id", + primaryjoin="""and_(FileAttachment.attachable_id == Card.id, + FileAttachment.attachable_type == 'Card')""", + cascade="all, delete-orphan", + lazy="dynamic", + ) def to_dict(self): """Convert card to dictionary""" diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py index 60f42f4..26894f1 100644 --- a/backend/app/models/comment.py +++ b/backend/app/models/comment.py @@ -30,6 +30,17 @@ class Comment(db.Model): onupdate=lambda: datetime.now(UTC), ) + # Relationships + attachments = db.relationship( + "FileAttachment", + foreign_keys="FileAttachment.attachable_id", + primaryjoin="""and_(FileAttachment.attachable_id == Comment.id, + FileAttachment.attachable_type == 'Comment')""", + cascade="all, delete-orphan", + lazy="dynamic", + overlaps="attachments", + ) + def to_dict(self): """Convert comment to dictionary""" return { diff --git a/backend/app/models/file_attachment.py b/backend/app/models/file_attachment.py new file mode 100644 index 0000000..232a705 --- /dev/null +++ b/backend/app/models/file_attachment.py @@ -0,0 +1,75 @@ +import uuid +from datetime import UTC, datetime + +from sqlalchemy import Index + +from app import db + + +class FileAttachment(db.Model): + """Polymorphic file attachment model for Cards, Comments, and other entities""" + + __tablename__ = "file_attachments" + + id = db.Column(db.Integer, primary_key=True) + uuid = db.Column( + db.String(36), nullable=False, unique=True, default=lambda: str(uuid.uuid4()) + ) + filename = db.Column(db.String(255), nullable=False) + original_name = db.Column(db.String(255), nullable=False) + file_type = db.Column(db.String(50), nullable=False) # 'image', 'pdf', 'document' + mime_type = db.Column(db.String(100), nullable=False) + file_size = db.Column(db.Integer, nullable=False) + + # MinIO storage information + minio_bucket = db.Column(db.String(100), nullable=False) + minio_object_name = db.Column(db.String(255), nullable=False, unique=True) + + # Thumbnail information (optional, for images) + thumbnail_minio_object_name = db.Column(db.String(255)) + thumbnail_minio_bucket = db.Column(db.String(100)) + + # Polymorphic relationship - can attach to different entity types + attachable_type = db.Column( + db.String(50), nullable=False + ) # 'Card', 'Comment', 'Epic' + attachable_id = db.Column(db.Integer, nullable=False) # ID of the attached entity + + # Upload metadata + uploaded_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(UTC)) + + # Relationships + uploader = db.relationship("User", backref="uploaded_files") + + # Indexes for efficient queries + __table_args__ = ( + Index("ix_file_attachments_attachable", "attachable_type", "attachable_id"), + Index("ix_file_attachments_user", "uploaded_by"), + Index("ix_file_attachments_uuid", "uuid"), + ) + + def to_dict(self): + """Convert file attachment to dictionary""" + return { + "id": self.id, + "uuid": self.uuid, + "filename": self.filename, + "original_name": self.original_name, + "file_type": self.file_type, + "mime_type": self.mime_type, + "file_size": self.file_size, + "minio_bucket": self.minio_bucket, + "minio_object_name": self.minio_object_name, + "thumbnail_minio_object_name": self.thumbnail_minio_object_name, + "thumbnail_minio_bucket": self.thumbnail_minio_bucket, + "attachable_type": self.attachable_type, + "attachable_id": self.attachable_id, + "uploaded_by": self.uploaded_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + def __repr__(self): + return f"" diff --git a/backend/app/routes/kanban/__init__.py b/backend/app/routes/kanban/__init__.py index 6ec9b8d..ea98768 100644 --- a/backend/app/routes/kanban/__init__.py +++ b/backend/app/routes/kanban/__init__.py @@ -6,8 +6,8 @@ kanban_bp = Blueprint("kanban", __name__) # Import all route modules to register their routes to this blueprint # fmt: off -from . import (boards, cards, checklists, comments, labels, # noqa: F401 E402 - lists) +from . import (boards, cards, checklists, comments, files, # noqa: F401 E402 + labels, lists) # fmt: on diff --git a/backend/app/routes/kanban/files.py b/backend/app/routes/kanban/files.py new file mode 100644 index 0000000..93924e0 --- /dev/null +++ b/backend/app/routes/kanban/files.py @@ -0,0 +1,299 @@ +"""Routes for file upload and management""" + +from io import BytesIO + +from flask import Response, request +from flask_jwt_extended import jwt_required +from werkzeug.datastructures import FileStorage + +from app.decorators import (get_current_user_id, load_card_owned, + load_comment_owned, load_file_accessible_by_uuid) +from app.schemas import (FileAttachmentWithUrlResponse, FileDeleteResponse, + MultipleFilesResponse) +from app.services.storage.attachment_service import AttachmentService +from app.services.storage.file_service import FileService + +from . import kanban_bp + + +@kanban_bp.route("/files/", methods=["GET"]) +@jwt_required() +@load_file_accessible_by_uuid +def get_file(file_uuid, file): + """Get file information with UUID-based URLs""" + attachment_service = AttachmentService() + file_info = attachment_service.get_attachment_with_url(file.id) + + return file_info, 200 + + +@kanban_bp.route("/files/", methods=["DELETE"]) +@jwt_required() +@load_file_accessible_by_uuid +def delete_file(file_uuid, file): + """Delete a file""" + attachment_service = AttachmentService() + success = attachment_service.delete_attachment(file.id) + + if not success: + return {"error": "Failed to delete file"}, 500 + + return FileDeleteResponse(message="File deleted", file_id=file.id).model_dump(), 200 + + +@kanban_bp.route("/cards//attachments", methods=["GET"]) +@jwt_required() +@load_card_owned +def get_card_attachments(card_id, card): + """Get all attachments for a card""" + attachment_service = AttachmentService() + attachments = attachment_service.get_card_attachments(card_id) + + # Get URLs for each attachment + files_with_urls = [] + for attachment in attachments: + file_info = attachment_service.get_attachment_with_url(attachment.id) + files_with_urls.append(file_info) + + return ( + MultipleFilesResponse( + files=files_with_urls, + count=len(attachments), + ).model_dump(), + 200, + ) + + +@kanban_bp.route("/cards//attachments", methods=["POST"]) +@jwt_required() +@load_card_owned +def upload_card_attachment(card_id, card): + """Upload a file to a specific card""" + return _upload_file(entity_type="Card", entity_id=card_id) + + +@kanban_bp.route("/comments//attachments", methods=["GET"]) +@jwt_required() +@load_comment_owned +def get_comment_attachments(comment_id, comment): + """Get all attachments for a comment""" + attachment_service = AttachmentService() + attachments = attachment_service.get_comment_attachments(comment_id) + + # Get URLs for each attachment + files_with_urls = [] + for attachment in attachments: + file_info = attachment_service.get_attachment_with_url(attachment.id) + files_with_urls.append(file_info) + + return ( + MultipleFilesResponse( + files=files_with_urls, + count=len(attachments), + ).model_dump(), + 200, + ) + + +@kanban_bp.route("/comments//attachments", methods=["POST"]) +@jwt_required() +@load_comment_owned +def upload_comment_attachment(comment_id, comment): + """Upload a file to a specific comment""" + return _upload_file(entity_type="Comment", entity_id=comment_id) + + +@kanban_bp.route("/files/upload", methods=["POST"]) +@jwt_required() +def upload_file(): + """Upload a file to an entity (Card, Comment, etc.)""" + # Validate request + entity_type = request.form.get("entity_type") + entity_id = request.form.get("entity_id") + + if not entity_type or not entity_id: + return {"error": "entity_type and entity_id are required"}, 400 + + # Validate entity_type + valid_types = ["Card", "Comment", "Epic", "Board"] + if entity_type not in valid_types: + return { + "error": f"Invalid entity_type: {entity_type}. Must be one of {valid_types}" + }, 400 + + return _upload_file(entity_type=entity_type, entity_id=int(entity_id)) + + +def _upload_file(entity_type: str, entity_id: int): + """ + Helper function to handle file upload + + Args: + entity_type: Type of entity (Card, Comment, etc.) + entity_id: ID of the entity + + Returns: + tuple: (response_dict, status_code) + """ + # Get file from request + if "file" not in request.files: + return {"error": "No file provided"}, 400 + + file: FileStorage = request.files["file"] + if file.filename == "": + return {"error": "No file selected"}, 400 + + # Get user ID + user_id = get_current_user_id() + + # Create BytesIO from file + file_data = BytesIO() + file.save(file_data) + file_size = len(file_data.getvalue()) + + # Get MIME type + mime_type = file.mimetype or "application/octet-stream" + + # Upload file + attachment_service = AttachmentService() + attachment = attachment_service.attach_file_to_entity( + entity_type=entity_type, + entity_id=entity_id, + file_data=file_data, + filename=file.filename, + content_type=mime_type, + file_size=file_size, + uploaded_by=user_id, + ) + + # Get attachment with URLs for response + file_info = attachment_service.get_attachment_with_url(attachment.id) + return FileAttachmentWithUrlResponse(**file_info).model_dump(), 201 + + +@kanban_bp.route("/files//download", methods=["GET"]) +@jwt_required() +@load_file_accessible_by_uuid +def download_file(file_uuid, file): + """ + Download a file through secure Flask proxy using UUID + + This route serves as a proxy to MinIO, hiding the MinIO server + from the frontend and ensuring authentication/authorization. + + Args: + file_uuid: UUID of the file attachment + file: FileAttachment (injected by @load_file_accessible_by_uuid decorator) + + Returns: + Response: File data with proper headers + """ + file_service = FileService() + + # Download file from MinIO + file_data = file_service.storage.download_file( + file.minio_bucket, file.minio_object_name + ) + + # Get file info for proper headers + file_info = file_service.storage.get_file_info( + file.minio_bucket, file.minio_object_name + ) + + # Create Flask Response with file data + return Response( + file_data.getvalue(), + mimetype=file.mime_type, + headers={ + "Content-Disposition": f'attachment; filename="{file.original_name}"', + "Content-Length": str(file_info["size"]), + "Cache-Control": "private, max-age=3600", # Cache for 1 hour + }, + ) + + +@kanban_bp.route("/files//thumbnail", methods=["GET"]) +@jwt_required() +@load_file_accessible_by_uuid +def download_thumbnail(file_uuid, file): + """ + Download a file thumbnail through secure Flask proxy using UUID + + This route serves as a proxy to MinIO for thumbnails, + ensuring authentication with JWT. + + Args: + file_uuid: UUID of the file attachment + file: FileAttachment (injected by @load_file_accessible_by_uuid decorator) + + Returns: + Response: Thumbnail image data with proper headers + """ + # Check if thumbnail exists + if not file.thumbnail_minio_bucket or not file.thumbnail_minio_object_name: + return {"error": "Thumbnail not available"}, 404 + + file_service = FileService() + + # Download thumbnail from MinIO + thumbnail_data = file_service.storage.download_file( + file.thumbnail_minio_bucket, + file.thumbnail_minio_object_name, + ) + + # Get thumbnail info for proper headers + thumb_info = file_service.storage.get_file_info( + file.thumbnail_minio_bucket, + file.thumbnail_minio_object_name, + ) + + # Create Flask Response with thumbnail data + return Response( + thumbnail_data.getvalue(), + mimetype="image/jpeg", + headers={ + "Content-Length": str(thumb_info["size"]), + "Cache-Control": "private, max-age=3600", + }, + ) + + +@kanban_bp.route("/files//view", methods=["GET"]) +@jwt_required() +@load_file_accessible_by_uuid +def view_file(file_uuid, file): + """ + View a file inline through secure Flask proxy using UUID + + Similar to download, but uses inline disposition so browsers + display the file instead of downloading it. + + Args: + file_uuid: UUID of the file attachment + file: FileAttachment (injected by @load_file_accessible_by_uuid decorator) + + Returns: + Response: File data with proper headers + """ + file_service = FileService() + + # Download file from MinIO + file_data = file_service.storage.download_file( + file.minio_bucket, file.minio_object_name + ) + + # Get file info for proper headers + file_info = file_service.storage.get_file_info( + file.minio_bucket, file.minio_object_name + ) + + # Create Flask Response with file data (inline disposition) + return Response( + file_data.getvalue(), + mimetype=file.mime_type, + headers={ + "Content-Disposition": f'inline; filename="{file.original_name}"', + "Content-Length": str(file_info["size"]), + "Cache-Control": "private, max-age=3600", # Cache for 1 hour + }, + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 59c90a4..6b2c18e 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -7,6 +7,10 @@ from app.schemas.check_item import CheckItemCreateRequest, CheckItemResponse from app.schemas.checklist import ChecklistCreateRequest, ChecklistResponse from app.schemas.comment import (CommentCreateRequest, CommentResponse, CommentWithUserResponse) +from app.schemas.file_attachment import (FileAttachmentWithUrlResponse, + FileDeleteResponse, FileUploadRequest, + MultipleFilesResponse, + ValidationError) from app.schemas.label import LabelCreateRequest, LabelResponse from app.schemas.list_model import ListCreateRequest, ListResponse from app.schemas.product import ProductCreateRequest, ProductResponse @@ -36,6 +40,13 @@ __all__ = [ # Comment schemas "CommentCreateRequest", "CommentResponse", + # File Attachment schemas + "FileAttachmentWithUrlResponse", + "FileDeleteResponse", + "FileUploadRequest", + "MultipleFilesResponse", + "ValidationError", + # Composite schemas "BoardWithDetailsResponse", "CardWithDetailsResponse", "CommentWithUserResponse", diff --git a/backend/app/schemas/file_attachment.py b/backend/app/schemas/file_attachment.py new file mode 100644 index 0000000..4e229af --- /dev/null +++ b/backend/app/schemas/file_attachment.py @@ -0,0 +1,72 @@ +"""Pydantic schemas for file attachments""" + +from datetime import datetime +from typing import Optional + +from pydantic import (BaseModel, ConfigDict, Field, field_serializer, + field_validator) + + +class FileUploadRequest(BaseModel): + """Schema for file upload request""" + + entity_type: str = Field(..., description="Type of entity (Card, Comment, etc.)") + entity_id: int = Field(..., description="ID of entity") + + @field_validator("entity_type") + @classmethod + def validate_entity_type(cls, v: str) -> str: + """Validate entity type is allowed""" + valid_types = ["Card", "Comment", "Epic", "Board"] + if v not in valid_types: + raise ValueError(f"Invalid entity_type: {v}. Must be one of {valid_types}") + return v + + model_config = ConfigDict(from_attributes=True) + + +class FileAttachmentWithUrlResponse(BaseModel): + """ + Schema for file attachment with secure URLs + + Security: Only exposes public-facing information. + Internal details (MinIO paths, IDs, etc.) are hidden. + """ + + uuid: str + original_name: str + file_type: str + file_size: int + created_at: datetime + download_url: str + thumbnail_url: Optional[str] = None + view_url: str + mime_type: str + + @field_serializer("created_at") + def serialize_created_at(self, value: datetime) -> str: + """Serialize datetime to ISO format string""" + return value.isoformat() if value else None + + model_config = ConfigDict(from_attributes=True) + + +class FileDeleteResponse(BaseModel): + """Schema for file delete response""" + + message: str = Field(..., description="Deletion status message") + file_id: int = Field(..., description="ID of deleted file") + + +class MultipleFilesResponse(BaseModel): + """Schema for multiple files response""" + + files: list[FileAttachmentWithUrlResponse] + count: int = Field(..., description="Number of files") + + +class ValidationError(BaseModel): + """Schema for validation errors""" + + error: str = Field(..., description="Error message") + details: Optional[dict] = None diff --git a/backend/app/services/storage/__init__.py b/backend/app/services/storage/__init__.py new file mode 100644 index 0000000..b75b97c --- /dev/null +++ b/backend/app/services/storage/__init__.py @@ -0,0 +1,20 @@ +"""Storage service module for file upload operations""" + +from app.services.storage.attachment_service import AttachmentService +from app.services.storage.base import StorageAdapter +from app.services.storage.file_processor import (FileProcessor, ImageProcessor, + PDFProcessor) +from app.services.storage.file_service import FileService +from app.services.storage.minio_client import MinIOStorageAdapter +from app.services.storage.storage_extension import storage + +__all__ = [ + "StorageAdapter", + "MinIOStorageAdapter", + "FileProcessor", + "ImageProcessor", + "PDFProcessor", + "FileService", + "AttachmentService", + "storage", +] diff --git a/backend/app/services/storage/attachment_service.py b/backend/app/services/storage/attachment_service.py new file mode 100644 index 0000000..82e97c9 --- /dev/null +++ b/backend/app/services/storage/attachment_service.py @@ -0,0 +1,231 @@ +"""Service for managing file attachments to entities""" + +from io import BytesIO +from typing import List, Optional + +from flask import current_app + +from app.models import Card, Comment, FileAttachment +from app.services.storage.file_service import FileService + + +class AttachmentService: + """Service for managing file attachments to Cards, Comments, etc.""" + + def __init__(self, file_service: Optional[FileService] = None): + """ + Initialize AttachmentService with dependency injection + + Args: + file_service: FileService instance + (defaults to new instance with dependency injection) + """ + self.file_service = file_service or FileService() + + def attach_file_to_card( + self, + card_id: int, + file_data: BytesIO, + filename: str, + content_type: str, + file_size: int, + uploaded_by: int, + ) -> FileAttachment: + """ + Attach a file to a card + + Args: + card_id: ID of the card + file_data: File data as BytesIO + filename: Original filename + content_type: MIME type + file_size: File size in bytes + uploaded_by: User ID who uploaded the file + + Returns: + FileAttachment: Created attachment record + """ + # Verify card exists + card = Card.query.get(card_id) + if not card: + raise ValueError(f"Card with ID {card_id} not found") + + # Upload file + return self.file_service.upload_file( + file_data=file_data, + filename=filename, + content_type=content_type, + file_size=file_size, + uploaded_by=uploaded_by, + attachable_type="Card", + attachable_id=card_id, + ) + + def attach_file_to_comment( + self, + comment_id: int, + file_data: BytesIO, + filename: str, + content_type: str, + file_size: int, + uploaded_by: int, + ) -> FileAttachment: + """ + Attach a file to a comment + + Args: + comment_id: ID of the comment + file_data: File data as BytesIO + filename: Original filename + content_type: MIME type + file_size: File size in bytes + uploaded_by: User ID who uploaded the file + + Returns: + FileAttachment: Created attachment record + """ + # Verify comment exists + comment = Comment.query.get(comment_id) + if not comment: + raise ValueError(f"Comment with ID {comment_id} not found") + + # Upload file + return self.file_service.upload_file( + file_data=file_data, + filename=filename, + content_type=content_type, + file_size=file_size, + uploaded_by=uploaded_by, + attachable_type="Comment", + attachable_id=comment_id, + ) + + def attach_file_to_entity( + self, + entity_type: str, + entity_id: int, + file_data: BytesIO, + filename: str, + content_type: str, + file_size: int, + uploaded_by: int, + ) -> FileAttachment: + """ + Attach a file to any entity type + + Args: + entity_type: Type of entity (Card, Comment, etc.) + entity_id: ID of the entity + file_data: File data as BytesIO + filename: Original filename + content_type: MIME type + file_size: File size in bytes + uploaded_by: User ID who uploaded the file + + Returns: + FileAttachment: Created attachment record + """ + # Validate entity type + valid_types = ["Card", "Comment", "Epic", "Board"] + if entity_type not in valid_types: + raise ValueError( + f"Invalid entity type: {entity_type}. Must be one of {valid_types}" + ) + + # Upload file + return self.file_service.upload_file( + file_data=file_data, + filename=filename, + content_type=content_type, + file_size=file_size, + uploaded_by=uploaded_by, + attachable_type=entity_type, + attachable_id=entity_id, + ) + + def get_card_attachments(self, card_id: int) -> List[FileAttachment]: + """ + Get all attachments for a card + + Args: + card_id: ID of the card + + Returns: + list: List of FileAttachment objects + """ + return self.file_service.get_files_for_entity("Card", card_id) + + def get_comment_attachments(self, comment_id: int) -> List[FileAttachment]: + """ + Get all attachments for a comment + + Args: + comment_id: ID of the comment + + Returns: + list: List of FileAttachment objects + """ + return self.file_service.get_files_for_entity("Comment", comment_id) + + def get_entity_attachments( + self, entity_type: str, entity_id: int + ) -> List[FileAttachment]: + """ + Get all attachments for any entity type + + Args: + entity_type: Type of entity (Card, Comment, etc.) + entity_id: ID of the entity + + Returns: + list: List of FileAttachment objects + """ + return self.file_service.get_files_for_entity(entity_type, entity_id) + + def get_attachment_with_url(self, attachment_id: int) -> dict: + """ + Get attachment with download URL + + Args: + attachment_id: ID of the attachment + + Returns: + dict: Attachment information with URLs + """ + return self.file_service.get_file_info(attachment_id) + + def delete_attachment(self, attachment_id: int) -> bool: + """ + Delete an attachment + + Args: + attachment_id: ID of the attachment + + Returns: + bool: True if successful + """ + return self.file_service.delete_file(attachment_id) + + def delete_entity_attachments(self, entity_type: str, entity_id: int) -> int: + """ + Delete all attachments for an entity (cascade delete) + + Args: + entity_type: Type of entity (Card, Comment, etc.) + entity_id: ID of the entity + + Returns: + int: Number of attachments deleted + """ + attachments = self.get_entity_attachments(entity_type, entity_id) + deleted_count = 0 + + for attachment in attachments: + if self.delete_attachment(attachment.id): + deleted_count += 1 + + current_app.logger.info( + f"Deleted {deleted_count} attachments for {entity_type} {entity_id}" + ) + + return deleted_count diff --git a/backend/app/services/storage/base.py b/backend/app/services/storage/base.py new file mode 100644 index 0000000..cd1637e --- /dev/null +++ b/backend/app/services/storage/base.py @@ -0,0 +1,113 @@ +"""Abstract base class for storage adapters""" + +from abc import ABC, abstractmethod +from io import BytesIO + + +class StorageAdapter(ABC): + """Abstract base class for storage adapters""" + + @abstractmethod + def upload_file( + self, + file_data: BytesIO, + bucket_name: str, + object_name: str, + content_type: str, + ) -> dict: + """ + Upload a file to storage + + Args: + file_data: File data as BytesIO + bucket_name: Name of the bucket + object_name: Name of the object in storage + content_type: MIME type of the file + + Returns: + dict: Upload result with metadata + """ + pass + + @abstractmethod + def download_file(self, bucket_name: str, object_name: str) -> BytesIO: + """ + Download a file from storage + + Args: + bucket_name: Name of the bucket + object_name: Name of the object in storage + + Returns: + BytesIO: File data + """ + pass + + @abstractmethod + def delete_file(self, bucket_name: str, object_name: str) -> bool: + """ + Delete a file from storage + + Args: + bucket_name: Name of the bucket + object_name: Name of the object in storage + + Returns: + bool: True if successful + """ + pass + + @abstractmethod + def generate_presigned_url( + self, bucket_name: str, object_name: str, expires_in: int = 3600 + ) -> str: + """ + Generate a presigned URL for file access + + Args: + bucket_name: Name of the bucket + object_name: Name of the object in storage + expires_in: URL expiration time in seconds + + Returns: + str: Presigned URL + """ + pass + + @abstractmethod + def file_exists(self, bucket_name: str, object_name: str) -> bool: + """ + Check if a file exists in storage + + Args: + bucket_name: Name of the bucket + object_name: Name of the object in storage + + Returns: + bool: True if file exists + """ + pass + + @abstractmethod + def get_file_info(self, bucket_name: str, object_name: str) -> dict: + """ + Get file metadata + + Args: + bucket_name: Name of the bucket + object_name: Name of the object in storage + + Returns: + dict: File metadata + """ + pass + + @abstractmethod + def ensure_bucket_exists(self, bucket_name: str) -> None: + """ + Ensure a bucket exists, create if it doesn't + + Args: + bucket_name: Name of the bucket + """ + pass diff --git a/backend/app/services/storage/file_processor.py b/backend/app/services/storage/file_processor.py new file mode 100644 index 0000000..d6aed72 --- /dev/null +++ b/backend/app/services/storage/file_processor.py @@ -0,0 +1,264 @@ +"""File processor classes for different file types""" + +from abc import ABC, abstractmethod +from io import BytesIO +from typing import Optional, Tuple + +from flask import current_app +from PIL import Image + + +class FileProcessor(ABC): + """Abstract base class for file processors""" + + @abstractmethod + def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool: + """ + Validate file + + Args: + file_data: File data as BytesIO + file_size: Size of the file in bytes + mime_type: MIME type of the file + + Returns: + bool: True if valid + """ + pass + + @abstractmethod + def process(self, file_data: BytesIO, mime_type: str) -> dict: + """ + Process file and return metadata + + Args: + file_data: File data as BytesIO + mime_type: MIME type of the file + + Returns: + dict: Processing results and metadata + """ + pass + + @abstractmethod + def get_file_type(self) -> str: + """Return the file type (image, pdf, document, etc.)""" + pass + + +class ImageProcessor(FileProcessor): + """Processor for image files""" + + def __init__(self): + self.supported_formats = { + "image/jpeg": "jpeg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg+xml", + } + + def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool: + """Validate image file""" + # Check file size + if file_size > current_app.config["MAX_UPLOAD_SIZE_IMAGE"]: + current_app.logger.error( + f"Image size {file_size} exceeds " + + "maximum {current_app.config['MAX_UPLOAD_SIZE_IMAGE']}" + ) + return False + + # Check MIME type + if mime_type not in current_app.config["ALLOWED_IMAGE_TYPES"]: + current_app.logger.error(f"Unsupported image MIME type: {mime_type}") + return False + + # Verify it's actually an image + try: + file_data.seek(0) + img = Image.open(file_data) + img.verify() + file_data.seek(0) + return True + except Exception as e: + current_app.logger.error(f"Image validation failed: {e}") + return False + + def process(self, file_data: BytesIO, mime_type: str) -> dict: + """Process image and extract metadata""" + file_data.seek(0) + img = Image.open(file_data) + + metadata = { + "width": img.width, + "height": img.height, + "format": img.format, + "mode": img.mode, + "file_type": "image", + } + + # Generate thumbnail + thumbnail_data = self._generate_thumbnail(img) + + file_data.seek(0) + return { + "metadata": metadata, + "thumbnail_data": thumbnail_data, + } + + def _generate_thumbnail( + self, img: Image.Image, size: Tuple[int, int] = (200, 200) + ) -> Optional[BytesIO]: + """ + Generate thumbnail for image + + Args: + img: PIL Image object + size: Thumbnail size (width, height) + + Returns: + BytesIO: Thumbnail data or None + """ + try: + # Create thumbnail + img_copy = img.copy() + img_copy.thumbnail(size, Image.Resampling.LANCZOS) + + # Convert RGBA to RGB if necessary (JPEG doesn't support transparency) + if img_copy.mode in ("RGBA", "LA", "P"): + # Create white background + background = Image.new("RGB", img_copy.size, (255, 255, 255)) + if img_copy.mode == "P": + img_copy = img_copy.convert("RGBA") + if img_copy.mode in ("RGBA", "LA"): + background.paste(img_copy, mask=img_copy.split()[-1]) + else: + background.paste(img_copy) + img_copy = background + elif img_copy.mode != "RGB": + img_copy = img_copy.convert("RGB") + + # Save to BytesIO + thumb_data = BytesIO() + img_copy.save(thumb_data, format="JPEG", quality=85) + thumb_data.seek(0) + + return thumb_data + except Exception as e: + current_app.logger.error(f"Thumbnail generation failed: {e}") + return None + + def get_file_type(self) -> str: + return "image" + + +class PDFProcessor(FileProcessor): + """Processor for PDF files""" + + def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool: + """Validate PDF file""" + # Check file size + if file_size > current_app.config["MAX_UPLOAD_SIZE_DOCUMENT"]: + current_app.logger.error( + f"PDF size {file_size} exceeds " + f"maximum {current_app.config['MAX_UPLOAD_SIZE_DOCUMENT']}" + ) + return False + + # Check MIME type + if mime_type not in current_app.config["ALLOWED_DOCUMENT_TYPES"]: + current_app.logger.error(f"Unsupported document MIME type: {mime_type}") + return False + + # Verify it's actually a PDF + try: + file_data.seek(0) + header = file_data.read(4) + file_data.seek(0) + if header != b"%PDF": + current_app.logger.error("Invalid PDF header") + return False + return True + except Exception as e: + current_app.logger.error(f"PDF validation failed: {e}") + return False + + def process(self, file_data: BytesIO, mime_type: str) -> dict: + """Process PDF and extract metadata""" + file_data.seek(0) + + # Basic PDF metadata extraction + # Note: For more detailed extraction, you might want to use PyPDF2 or similar + metadata = { + "file_type": "pdf", + } + + return { + "metadata": metadata, + "thumbnail_data": None, # PDFs don't have thumbnails by default + } + + def get_file_type(self) -> str: + return "pdf" + + +class DocumentProcessor(FileProcessor): + """Processor for other document types (placeholder for future expansion)""" + + def validate(self, file_data: BytesIO, file_size: int, mime_type: str) -> bool: + """Validate document file""" + # Basic validation - can be extended for specific document types + if file_size > current_app.config["MAX_UPLOAD_SIZE_DOCUMENT"]: + current_app.logger.error("Document size exceeds maximum") + return False + return True + + def process(self, file_data: BytesIO, mime_type: str) -> dict: + """Process document""" + return { + "metadata": {"file_type": "document"}, + "thumbnail_data": None, + } + + def get_file_type(self) -> str: + return "document" + + +class ProcessorFactory: + """Factory for creating appropriate file processors""" + + @staticmethod + def get_processor(mime_type: str) -> FileProcessor: + """ + Get appropriate processor for MIME type + + Args: + mime_type: MIME type of the file + + Returns: + FileProcessor: Appropriate processor instance + """ + if mime_type.startswith("image/"): + return ImageProcessor() + elif mime_type == "application/pdf": + return PDFProcessor() + else: + return DocumentProcessor() + + @staticmethod + def get_file_type_from_mime(mime_type: str) -> str: + """ + Get file type category from MIME type + + Args: + mime_type: MIME type of the file + + Returns: + str: File type category + """ + if mime_type.startswith("image/"): + return "image" + elif mime_type == "application/pdf": + return "pdf" + else: + return "document" diff --git a/backend/app/services/storage/file_service.py b/backend/app/services/storage/file_service.py new file mode 100644 index 0000000..6bd0995 --- /dev/null +++ b/backend/app/services/storage/file_service.py @@ -0,0 +1,315 @@ +"""Main file service for handling file uploads""" + +from io import BytesIO +from typing import Any, Dict, Optional + +from flask import current_app + +from app import db +from app.models import FileAttachment +from app.services.storage.base import StorageAdapter +from app.services.storage.file_processor import ProcessorFactory + + +class FileService: + """Service for handling file upload and storage operations""" + + def __init__(self, storage_adapter: Optional[StorageAdapter] = None): + """ + Initialize FileService with dependency injection + + Args: + storage_adapter: Storage adapter + (optional, defaults to factory-provided adapter) + """ + if storage_adapter: + self.storage = storage_adapter + else: + from app.services.storage.storage_factory import StorageFactory + + self.storage = StorageFactory.get_storage() + + def upload_file( + self, + file_data: BytesIO, + filename: str, + content_type: str, + file_size: int, + uploaded_by: int, + attachable_type: str, + attachable_id: int, + ) -> FileAttachment: + """ + Upload a file and create FileAttachment record + + Args: + file_data: File data as BytesIO + filename: Original filename + content_type: MIME type + file_size: File size in bytes + uploaded_by: User ID who uploaded the file + attachable_type: Type of entity (Card, Comment, etc.) + attachable_id: ID of the entity + + Returns: + FileAttachment: Created attachment record + """ + # Get appropriate processor + processor = ProcessorFactory.get_processor(content_type) + + # Validate file + if not processor.validate(file_data, file_size, content_type): + raise ValueError("File validation failed") + + # Process file and get metadata (this also generates thumbnail) + file_data.seek(0) # Ensure file is at start + process_result = processor.process(file_data, content_type) + metadata = process_result["metadata"] + thumbnail_data = process_result["thumbnail_data"] + + # Seek back to start for main file upload + file_data.seek(0) + + # Determine bucket + bucket_name = self._get_bucket_for_type(content_type) + + # Generate unique object name + object_name = self.storage.generate_unique_object_name(filename) + + # Upload main file + self.storage.upload_file( + file_data, bucket_name, object_name, content_type, file_size + ) + + # Upload thumbnail if available + thumbnail_info = {} + if thumbnail_data: + try: + thumb_bucket = current_app.config["MINIO_THUMBNAILS_BUCKET"] + thumb_object_name = f"thumb_{object_name}" + + # Get thumbnail length + thumbnail_data.seek(0, 2) + thumb_length = thumbnail_data.tell() + thumbnail_data.seek(0) + + # Upload thumbnail + self.storage.upload_file( + thumbnail_data, + thumb_bucket, + thumb_object_name, + "image/jpeg", + length=thumb_length, + ) + + thumbnail_info = { + "thumbnail_minio_object_name": thumb_object_name, + "thumbnail_minio_bucket": thumb_bucket, + } + + current_app.logger.info( + f"Thumbnail uploaded: {thumb_bucket}/{thumb_object_name}" + ) + except Exception as e: + current_app.logger.error(f"Failed to upload thumbnail: {e}") + # Continue without thumbnail + + # Create database record + attachment = FileAttachment( + filename=object_name, + original_name=filename, + file_type=metadata.get("file_type", "document"), + mime_type=content_type, + file_size=file_size, + minio_bucket=bucket_name, + minio_object_name=object_name, + uploaded_by=uploaded_by, + attachable_type=attachable_type, + attachable_id=attachable_id, + **thumbnail_info, + ) + + db.session.add(attachment) + db.session.commit() + + current_app.logger.info( + f"File uploaded: {filename} -> {bucket_name}/{object_name}" + ) + + return attachment + + def delete_file(self, attachment_id: int) -> bool: + """ + Delete a file and its attachment record + + Args: + attachment_id: ID of the FileAttachment + + Returns: + bool: True if successful + """ + attachment = db.session.get(FileAttachment, attachment_id) + if not attachment: + return False + + # Delete from MinIO + try: + self.storage.delete_file( + attachment.minio_bucket, attachment.minio_object_name + ) + + # Delete thumbnail if exists + if ( + attachment.thumbnail_minio_bucket + and attachment.thumbnail_minio_object_name + ): + self.storage.delete_file( + attachment.thumbnail_minio_bucket, + attachment.thumbnail_minio_object_name, + ) + except Exception as e: + current_app.logger.error(f"Error deleting file from MinIO: {e}") + + # Delete from database + db.session.delete(attachment) + db.session.commit() + + current_app.logger.info(f"File deleted: {attachment.original_name}") + return True + + def get_file_url(self, attachment_id: int, expires_in: int = 3600) -> str: + """ + Generate a presigned URL for file access + + Args: + attachment_id: ID of the FileAttachment + expires_in: URL expiration time in seconds + + Returns: + str: Presigned URL + """ + attachment = db.session.get(FileAttachment, attachment_id) + if not attachment: + raise ValueError("Attachment not found") + + return self.storage.generate_presigned_url( + attachment.minio_bucket, + attachment.minio_object_name, + expires_in, + ) + + def get_thumbnail_url( + self, attachment_id: int, expires_in: int = 3600 + ) -> Optional[str]: + """ + Generate a presigned URL for thumbnail access + + Args: + attachment_id: ID of the FileAttachment + expires_in: URL expiration time in seconds + + Returns: + str: Presigned URL or None if no thumbnail + """ + attachment = db.session.get(FileAttachment, attachment_id) + if not attachment or not attachment.thumbnail_minio_bucket: + return None + + return self.storage.generate_presigned_url( + attachment.thumbnail_minio_bucket, + attachment.thumbnail_minio_object_name, + expires_in, + ) + + def _get_bucket_for_type(self, mime_type: str) -> str: + """ + Get appropriate bucket for MIME type + + Args: + mime_type: MIME type of the file + + Returns: + str: Bucket name + """ + if mime_type.startswith("image/"): + return current_app.config["MINIO_IMAGES_BUCKET"] + elif mime_type == "application/pdf": + return current_app.config["MINIO_DOCUMENTS_BUCKET"] + else: + return current_app.config["MINIO_DOCUMENTS_BUCKET"] + + def get_file_by_id(self, attachment_id: int) -> Optional[FileAttachment]: + """ + Get file attachment by ID + + Args: + attachment_id: ID of the FileAttachment + + Returns: + FileAttachment or None + """ + return db.session.get(FileAttachment, attachment_id) + + def get_files_for_entity( + self, attachable_type: str, attachable_id: int + ) -> list[FileAttachment]: + """ + Get all files attached to an entity + + Args: + attachable_type: Type of entity (Card, Comment, etc.) + attachable_id: ID of the entity + + Returns: + list: List of FileAttachment objects + """ + return FileAttachment.query.filter_by( + attachable_type=attachable_type, attachable_id=attachable_id + ).all() + + def get_file_info(self, attachment_id: int) -> Optional[Dict[str, Any]]: + """ + Get file information including UUID-based secure URLs + + Security: Only exposes public-facing information. + Internal details (MinIO paths, IDs, entity references) are hidden. + + Uses UUID for secure file access: + - UUIDs are non-sequential and hard to guess + - All endpoints require JWT authentication + - MinIO server stays hidden from public + - Frontend uses blob URLs for secure image display + + Args: + attachment_id: ID of the FileAttachment + + Returns: + dict: File information with UUID-based URLs + """ + attachment = self.get_file_by_id(attachment_id) + if not attachment: + return None + + # Use UUID-based secure URLs (all require JWT authentication) + download_url = f"/files/{attachment.uuid}/download" + thumbnail_url = ( + f"/files/{attachment.uuid}/thumbnail" + if attachment.thumbnail_minio_object_name + else None + ) + view_url = f"/files/{attachment.uuid}/view" + + # Only return public-facing fields (security: hide internal implementation) + return { + "uuid": attachment.uuid, + "original_name": attachment.original_name, + "file_type": attachment.file_type, + "file_size": attachment.file_size, + "mime_type": attachment.mime_type, + "created_at": attachment.created_at.isoformat() + if attachment.created_at + else None, + "download_url": download_url, + "thumbnail_url": thumbnail_url, + "view_url": view_url, + } diff --git a/backend/app/services/storage/minio_client.py b/backend/app/services/storage/minio_client.py new file mode 100644 index 0000000..e652b27 --- /dev/null +++ b/backend/app/services/storage/minio_client.py @@ -0,0 +1,222 @@ +"""MinIO storage adapter implementation""" + +import uuid +from io import BytesIO +from typing import Optional + +from flask import current_app +from minio import Minio +from minio.error import S3Error + +from app.services.storage.base import StorageAdapter + + +class MinIOStorageAdapter(StorageAdapter): + """MinIO implementation of StorageAdapter""" + + def __init__(self, minio_client: Optional[Minio] = None): + """ + Initialize MinIO adapter with client dependency + + Args: + minio_client: MinIO client + instance (optional, defaults to extension's client) + """ + if minio_client is not None: + self.client = minio_client + else: + # Get client from storage extension (dependency injection) + from app.services.storage.storage_extension import storage + + self.client = storage.get_client() + + def upload_file( + self, + file_data: BytesIO, + bucket_name: str, + object_name: str, + content_type: str, + length: Optional[int] = None, + ) -> dict: + """ + Upload a file to MinIO + + Args: + file_data: File data as BytesIO + bucket_name: Name of bucket + object_name: Name of the object in MinIO + content_type: MIME type of the file + length: File size in bytes (optional, will be determined if not provided) + + Returns: + dict: Upload result with metadata + """ + try: + # Get file length if not provided + if length is None: + file_data.seek(0, 2) # Seek to end + length = file_data.tell() + file_data.seek(0) # Seek back to start + + # Ensure bucket exists + self.ensure_bucket_exists(bucket_name) + + # Upload file + result = self.client.put_object( + bucket_name, + object_name, + file_data, + length=length, + content_type=content_type, + ) + + return { + "success": True, + "bucket": bucket_name, + "object_name": object_name, + "size": length, + "etag": result.etag, + "version_id": result.version_id + if hasattr(result, "version_id") + else None, + } + except S3Error as e: + current_app.logger.error(f"MinIO upload error: {e}") + raise + + def download_file(self, bucket_name: str, object_name: str) -> BytesIO: + """ + Download a file from MinIO + + Args: + bucket_name: Name of bucket + object_name: Name of the object in MinIO + + Returns: + BytesIO: File data + """ + try: + response = self.client.get_object(bucket_name, object_name) + return BytesIO(response.read()) + except S3Error as e: + current_app.logger.error(f"MinIO download error: {e}") + raise + + def delete_file(self, bucket_name: str, object_name: str) -> bool: + """ + Delete a file from MinIO + + Args: + bucket_name: Name of bucket + object_name: Name of the object in MinIO + + Returns: + bool: True if successful + """ + try: + self.client.remove_object(bucket_name, object_name) + return True + except S3Error as e: + current_app.logger.error(f"MinIO delete error: {e}") + return False + + def generate_presigned_url( + self, bucket_name: str, object_name: str, expires_in: int = 3600 + ) -> str: + """ + Generate a presigned URL for file access + + Args: + bucket_name: Name of bucket + object_name: Name of the object in MinIO + expires_in: URL expiration time in seconds + + Returns: + str: Presigned URL + """ + try: + url = self.client.presigned_get_object( + bucket_name, object_name, expires=expires_in + ) + return url + except S3Error as e: + current_app.logger.error(f"MinIO presigned URL error: {e}") + raise + + def file_exists(self, bucket_name: str, object_name: str) -> bool: + """ + Check if a file exists in MinIO + + Args: + bucket_name: Name of bucket + object_name: Name of the object in MinIO + + Returns: + bool: True if file exists + """ + try: + self.client.stat_object(bucket_name, object_name) + return True + except S3Error as e: + if e.code == "NoSuchKey": + return False + current_app.logger.error(f"MinIO check exists error: {e}") + return False + + def get_file_info(self, bucket_name: str, object_name: str) -> dict: + """ + Get file metadata from MinIO + + Args: + bucket_name: Name of bucket + object_name: Name of the object in MinIO + + Returns: + dict: File metadata + """ + try: + stat = self.client.stat_object(bucket_name, object_name) + return { + "size": stat.size, + "last_modified": stat.last_modified, + "etag": stat.etag, + "content_type": stat.content_type, + } + except S3Error as e: + current_app.logger.error(f"MinIO get file info error: {e}") + raise + + def ensure_bucket_exists(self, bucket_name: str) -> None: + """ + Ensure a bucket exists, create if it doesn't + + Args: + bucket_name: Name of the bucket + """ + try: + if not self.client.bucket_exists(bucket_name): + self.client.make_bucket(bucket_name) + current_app.logger.info(f"Created bucket: {bucket_name}") + except S3Error as e: + current_app.logger.error(f"MinIO ensure bucket error: {e}") + raise + + def generate_unique_object_name(self, original_filename: str) -> str: + """ + Generate a unique object name for a file + + Args: + original_filename: Original filename + + Returns: + str: Unique object name + """ + # Extract file extension + ext = ( + original_filename.rsplit(".", 1)[1].lower() + if "." in original_filename + else "" + ) + # Generate unique filename with UUID + unique_name = f"{uuid.uuid4().hex}" + return f"{unique_name}.{ext}" if ext else unique_name diff --git a/backend/app/services/storage/mock_adapter.py b/backend/app/services/storage/mock_adapter.py new file mode 100644 index 0000000..206d231 --- /dev/null +++ b/backend/app/services/storage/mock_adapter.py @@ -0,0 +1,217 @@ +"""Mock storage adapter for testing (in-memory storage)""" + +import uuid +from datetime import datetime, timezone +from io import BytesIO + +from app.services.storage.base import StorageAdapter + + +class MockStorageAdapter(StorageAdapter): + """In-memory storage adapter for testing purposes""" + + # Class-level storage to persist across instances + _files = {} # {(bucket, object_name): BytesIO} + _file_info = {} # {(bucket, object_name): metadata} + _buckets = set() # Track created buckets + + def __init__(self): + """Initialize mock storage (storage is shared at class level)""" + pass + + def upload_file( + self, + file_data: BytesIO, + bucket_name: str, + object_name: str, + content_type: str, + length: int = None, + ) -> dict: + """ + Upload a file to in-memory storage + + Args: + file_data: File data as BytesIO + bucket_name: Name of bucket + object_name: Name of object in storage + content_type: MIME type of file + length: File size in bytes (optional) + + Returns: + dict: Upload result with metadata + """ + # Ensure bucket exists + self.ensure_bucket_exists(bucket_name) + + # Get file length if not provided + if length is None: + file_data.seek(0, 2) # Seek to end + length = file_data.tell() + file_data.seek(0) # Seek back to start + + # Store file data (create a copy to avoid reference issues) + file_data.seek(0) + file_copy = BytesIO(file_data.read()) + + # Store in memory + key = (bucket_name, object_name) + self._files[key] = file_copy + + # Store metadata + metadata = { + "size": length, + "content_type": content_type, + "last_modified": datetime.now(timezone.utc), + "etag": uuid.uuid4().hex, + } + self._file_info[key] = metadata + + return { + "success": True, + "bucket": bucket_name, + "object_name": object_name, + "size": length, + "etag": metadata["etag"], + } + + def download_file(self, bucket_name: str, object_name: str) -> BytesIO: + """ + Download a file from in-memory storage + + Args: + bucket_name: Name of bucket + object_name: Name of the object in storage + + Returns: + BytesIO: File data + + Raises: + ValueError: If file doesn't exist + """ + key = (bucket_name, object_name) + if key not in self._files: + raise ValueError(f"File {bucket_name}/{object_name} not found") + + # Create a copy to avoid modifying the original + self._files[key].seek(0) + return BytesIO(self._files[key].read()) + + def delete_file(self, bucket_name: str, object_name: str) -> bool: + """ + Delete a file from in-memory storage + + Args: + bucket_name: Name of bucket + object_name: Name of the object in storage + + Returns: + bool: True if successful, False if file doesn't exist + """ + key = (bucket_name, object_name) + if key not in self._files: + return False + + # Remove from storage + del self._files[key] + del self._file_info[key] + + return True + + def generate_presigned_url( + self, bucket_name: str, object_name: str, expires_in: int = 3600 + ) -> str: + """ + Generate a mock presigned URL + + In tests, this returns a fake URL that looks like a real MinIO URL + but doesn't actually need to work since we use Flask proxy routes. + + Args: + bucket_name: Name of bucket + object_name: Name of the object in storage + expires_in: URL expiration time in seconds (ignored in mock) + + Returns: + str: Mock presigned URL + """ + return f"http://minio:9000/{bucket_name}/{object_name}?X-Amz-Token=mock" + + def file_exists(self, bucket_name: str, object_name: str) -> bool: + """ + Check if a file exists in in-memory storage + + Args: + bucket_name: Name of bucket + object_name: Name of the object in storage + + Returns: + bool: True if file exists + """ + return (bucket_name, object_name) in self._files + + def get_file_info(self, bucket_name: str, object_name: str) -> dict: + """ + Get file metadata from in-memory storage + + Args: + bucket_name: Name of bucket + object_name: Name of the object in storage + + Returns: + dict: File metadata + + Raises: + ValueError: If file doesn't exist + """ + key = (bucket_name, object_name) + if key not in self._file_info: + raise ValueError(f"File {bucket_name}/{object_name} not found") + + return self._file_info[key].copy() + + def ensure_bucket_exists(self, bucket_name: str) -> None: + """ + Ensure a bucket exists, create if it doesn't + + Args: + bucket_name: Name of bucket + """ + if bucket_name not in self._buckets: + self._buckets.add(bucket_name) + + def generate_unique_object_name(self, original_filename: str) -> str: + """ + Generate a unique object name for a file + + Args: + original_filename: Original filename + + Returns: + str: Unique object name + """ + # Extract file extension + ext = ( + original_filename.rsplit(".", 1)[1].lower() + if "." in original_filename + else "" + ) + # Generate unique filename with UUID + unique_name = f"{uuid.uuid4().hex}" + return f"{unique_name}.{ext}" if ext else unique_name + + @classmethod + def clear_all(cls) -> None: + """Clear all files and buckets (useful for test cleanup)""" + cls._files.clear() + cls._file_info.clear() + cls._buckets.clear() + + @classmethod + def get_all_files(cls) -> list: + """ + Get all stored files (useful for test assertions) + + Returns: + list: List of (bucket, object_name) tuples + """ + return list(cls._files.keys()) diff --git a/backend/app/services/storage/storage_extension.py b/backend/app/services/storage/storage_extension.py new file mode 100644 index 0000000..432abe8 --- /dev/null +++ b/backend/app/services/storage/storage_extension.py @@ -0,0 +1,128 @@ +"""Flask extension for MinIO storage client initialization""" + +import logging + +from flask import Flask +from minio import Minio +from minio.error import S3Error + + +class StorageExtension: + """Flask extension for managing MinIO storage client""" + + def __init__(self, app: Flask = None): + """ + Initialize StorageExtension + + Args: + app: Flask application instance (optional) + """ + self.app = app + self.client = None + self.logger = logging.getLogger(__name__) + if app is not None: + self.init_app(app) + + def init_app(self, app: Flask) -> None: + """ + Initialize the extension with Flask app + + Args: + app: Flask application instance + """ + # Store extension on app + app.extensions = getattr(app, "extensions", {}) + app.extensions["storage"] = self + + # Store app reference for later use + self.app = app + + # Initialize and validate MinIO client immediately at app startup + self._initialize_and_validate(app) + + def _initialize_and_validate(self, app: Flask) -> None: + """ + Initialize MinIO client and validate connection + + Args: + app: Flask application instance + """ + try: + # Create MinIO client + self.client = Minio( + app.config["MINIO_ENDPOINT"], + access_key=app.config["MINIO_ACCESS_KEY"], + secret_key=app.config["MINIO_SECRET_KEY"], + secure=app.config["MINIO_USE_SSL"], + ) + + # Validate connection by listing buckets + self.client.list_buckets() + app.logger.info("MinIO client initialized and validated successfully") + + # Ensure all required buckets exist + self._ensure_all_buckets_exist(app) + + except S3Error as e: + app.logger.error(f"Failed to initialize MinIO client: {e}") + raise RuntimeError( + f"Failed to connect to MinIO at {app.config['MINIO_ENDPOINT']}. " + "Please check your MINIO_ENDPOINT, MINIO_ACCESS_KEY, " + "and MINIO_SECRET_KEY configuration." + ) from e + except KeyError as e: + app.logger.error(f"Missing MinIO configuration: {e}") + raise RuntimeError( + f"Missing required MinIO configuration: {e}. " + "Please ensure MINIO_ENDPOINT, MINIO_ACCESS_KEY, " + "MINIO_SECRET_KEY are set." + ) from e + + def _ensure_all_buckets_exist(self, app: Flask) -> None: + """ + Ensure all required buckets exist + Creates buckets if they don't exist + + Args: + app: Flask application instance + """ + buckets = [ + app.config["MINIO_IMAGES_BUCKET"], + app.config["MINIO_DOCUMENTS_BUCKET"], + app.config["MINIO_THUMBNAILS_BUCKET"], + ] + + for bucket_name in buckets: + try: + if not self.client.bucket_exists(bucket_name): + self.client.make_bucket(bucket_name) + app.logger.info(f"Created MinIO bucket: {bucket_name}") + else: + app.logger.debug(f"MinIO bucket already exists: {bucket_name}") + except S3Error as e: + app.logger.error(f"Failed to ensure bucket {bucket_name}: {e}") + raise RuntimeError( + f"Failed to create MinIO bucket '{bucket_name}': {e}" + ) from e + + def get_client(self) -> Minio: + """ + Get the MinIO client instance + + Returns: + Minio: Initialized MinIO client + + Raises: + RuntimeError: If client has not been initialized + """ + if self.client is None: + raise RuntimeError( + "MinIO client has not been initialized. " + "Ensure the Flask app context is active " + "and before_first_request has run." + ) + return self.client + + +# Create a singleton instance +storage = StorageExtension() diff --git a/backend/app/services/storage/storage_factory.py b/backend/app/services/storage/storage_factory.py new file mode 100644 index 0000000..8280683 --- /dev/null +++ b/backend/app/services/storage/storage_factory.py @@ -0,0 +1,56 @@ +"""Storage factory for creating storage adapters""" + +from flask import current_app + + +class StorageFactory: + """Factory for creating storage adapter instances""" + + @staticmethod + def get_storage(): + """ + Get the appropriate storage adapter based on configuration + + Returns: + StorageAdapter: Configured storage adapter instance + + Factory Pattern Benefits: + - Centralized adapter selection logic + - Easy to add new storage types (S3, GCS, etc.) + - Single Responsibility Principle - FileService only handles file operations + - Easy to test by injecting mock adapters + """ + # Check if we should use mock storage + if current_app.config.get("USE_MOCK_STORAGE", False): + from app.services.storage.mock_adapter import MockStorageAdapter + + return MockStorageAdapter() + + # Default to MinIO for production/development + from app.services.storage.minio_client import MinIOStorageAdapter + + return MinIOStorageAdapter() + + @staticmethod + def get_storage_for_testing(): + """ + Get mock storage adapter explicitly for testing + + Returns: + StorageAdapter: MockStorageAdapter instance + """ + from app.services.storage.mock_adapter import MockStorageAdapter + + return MockStorageAdapter() + + @staticmethod + def get_minio_storage(): + """ + Get MinIO storage adapter explicitly + + Returns: + StorageAdapter: MinIOStorageAdapter instance + """ + from app.services.storage.minio_client import MinIOStorageAdapter + + return MinIOStorageAdapter() diff --git a/backend/migrations/versions/c5b574480b80_add_file_upload.py b/backend/migrations/versions/c5b574480b80_add_file_upload.py new file mode 100644 index 0000000..224ddf4 --- /dev/null +++ b/backend/migrations/versions/c5b574480b80_add_file_upload.py @@ -0,0 +1,54 @@ +"""add file upload + +Revision ID: c5b574480b80 +Revises: 1c0b9dfbd933 +Create Date: 2026-03-20 20:16:04.599906 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c5b574480b80' +down_revision = '1c0b9dfbd933' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('file_attachments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('original_name', sa.String(length=255), nullable=False), + sa.Column('file_type', sa.String(length=50), nullable=False), + sa.Column('mime_type', sa.String(length=100), nullable=False), + sa.Column('file_size', sa.Integer(), nullable=False), + sa.Column('minio_bucket', sa.String(length=100), nullable=False), + sa.Column('minio_object_name', sa.String(length=255), nullable=False), + sa.Column('thumbnail_minio_object_name', sa.String(length=255), nullable=True), + sa.Column('thumbnail_minio_bucket', sa.String(length=100), nullable=True), + sa.Column('attachable_type', sa.String(length=50), nullable=False), + sa.Column('attachable_id', sa.Integer(), nullable=False), + sa.Column('uploaded_by', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('minio_object_name') + ) + with op.batch_alter_table('file_attachments', schema=None) as batch_op: + batch_op.create_index('ix_file_attachments_attachable', ['attachable_type', 'attachable_id'], unique=False) + batch_op.create_index('ix_file_attachments_user', ['uploaded_by'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('file_attachments', schema=None) as batch_op: + batch_op.drop_index('ix_file_attachments_user') + batch_op.drop_index('ix_file_attachments_attachable') + + op.drop_table('file_attachments') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/e9515e29ef8b_add_uuid_to_file_attachments.py b/backend/migrations/versions/e9515e29ef8b_add_uuid_to_file_attachments.py new file mode 100644 index 0000000..3b63a89 --- /dev/null +++ b/backend/migrations/versions/e9515e29ef8b_add_uuid_to_file_attachments.py @@ -0,0 +1,59 @@ +"""Add UUID to file_attachments + +Revision ID: e9515e29ef8b +Revises: c5b574480b80 +Create Date: 2026-03-20 21:58:22.006727 + +""" +from alembic import op +import sqlalchemy as sa +import uuid + + +# revision identifiers, used by Alembic. +revision = 'e9515e29ef8b' +down_revision = 'c5b574480b80' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # Step 1: Add UUID column as nullable + op.add_column('file_attachments', sa.Column('uuid', sa.String(length=36), nullable=True)) + + # Step 2: Populate UUID for existing rows + from sqlalchemy.sql import table, column, select, update + + file_attachments_table = table('file_attachments', + column('id', sa.Integer), + column('uuid', sa.String(36)) + ) + + connection = op.get_bind() + + # Get all existing file attachments + result = connection.execute(select(file_attachments_table.c.id)) + for row in result: + new_uuid = str(uuid.uuid4()) + connection.execute( + file_attachments_table.update() + .where(file_attachments_table.c.id == row[0]) + .values(uuid=new_uuid) + ) + + # Step 3: Make UUID not null and add constraints + op.alter_column('file_attachments', 'uuid', nullable=False) + op.create_index('ix_file_attachments_uuid', 'file_attachments', ['uuid'], unique=False) + op.create_unique_constraint('uq_file_attachments_uuid', 'file_attachments', ['uuid']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_file_attachments_uuid', 'file_attachments', type_='unique') + op.drop_index('ix_file_attachments_uuid', table_name='file_attachments') + op.drop_column('file_attachments', 'uuid') + + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt index f7d67aa..b1282ad 100644 --- a/backend/requirements/base.txt +++ b/backend/requirements/base.txt @@ -10,9 +10,11 @@ Werkzeug==3.0.1 SQLAlchemy==2.0.23 celery[redis]==5.3.6 pydantic==2.5.3 +minio==7.2.0 +Pillow==10.1.0 pytest==7.4.3 pytest-flask==1.3.0 pytest-cov==4.1.0 pytest-mock==3.12.0 factory-boy==3.3.0 -faker==20.1.0 \ No newline at end of file +faker==20.1.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 87790f6..67d42d1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,11 +1,14 @@ """Pytest configuration and fixtures""" import logging +from io import BytesIO import pytest from faker import Faker +from PIL import Image from app import create_app, db -from app.models import Order, OrderItem, Product, User +from app.models import (Board, Card, Comment, List, Order, OrderItem, Product, + User) log = logging.getLogger(__name__) fake = Faker() @@ -225,3 +228,91 @@ def order(db_session, regular_user, products): order.total_amount = total_amount db_session.commit() return order + + +# ============ File Upload Fixtures ============ + + +@pytest.fixture +def test_image_file(): + """Create a test image file as BytesIO""" + # Create a simple red PNG image using PIL + img = Image.new("RGB", (10, 10), color="red") + img_io = BytesIO() + img.save(img_io, format="PNG") + img_io.seek(0) + return img_io + + +@pytest.fixture +def test_large_image_file(): + """Create a larger test image (50KB)""" + # Create a larger image using PIL + img = Image.new("RGB", (200, 200), color="blue") + img_io = BytesIO() + img.save(img_io, format="PNG") + img_io.seek(0) + return img_io + + +@pytest.fixture +def test_pdf_file(): + """Create a test PDF file as BytesIO""" + # Minimal valid PDF + pdf_data = b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n>>\nendobj\n%%EOF" + return BytesIO(pdf_data) + + +@pytest.fixture +def test_board(db_session, regular_user): + """Create a test board""" + board = Board( + user_id=regular_user.id, + name=fake.sentence(nb_words=4)[:-1], + description=fake.paragraph(), + ) + db_session.add(board) + db_session.commit() + return board + + +@pytest.fixture +def test_list(db_session, test_board): + """Create a test list""" + lst = List( + board_id=test_board.id, + name=fake.sentence(nb_words=3)[:-1], + pos=0, + ) + db_session.add(lst) + db_session.commit() + return lst + + +@pytest.fixture +def test_card(db_session, test_list): + """Create a test card""" + card = Card( + list_id=test_list.id, + name=fake.sentence(nb_words=4)[:-1], + description=fake.paragraph(), + pos=0, + due=None, + board_id=test_list.board_id, + ) + db_session.add(card) + db_session.commit() + return card + + +@pytest.fixture +def test_comment(db_session, test_card, regular_user): + """Create a test comment""" + comment = Comment( + card_id=test_card.id, + user_id=regular_user.id, + text=fake.paragraph(), + ) + db_session.add(comment) + db_session.commit() + return comment diff --git a/backend/tests/routes/test_files.py b/backend/tests/routes/test_files.py new file mode 100644 index 0000000..da0dc82 --- /dev/null +++ b/backend/tests/routes/test_files.py @@ -0,0 +1,372 @@ +"""Integration tests for file upload routes""" + +import pytest + + +@pytest.mark.integration +def test_upload_image_to_card(client, auth_headers, test_card, test_image_file): + """Test uploading an image to a card""" + data = { + "file": (test_image_file, "test.png", "image/png"), + } + + response = client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 201 + file_data = response.get_json() + # Only check public-facing fields + assert file_data["original_name"] == "test.png" + assert file_data["file_type"] == "image" + assert file_data["file_size"] > 0 + assert file_data["mime_type"] == "image/png" # Mime type is now exposed + assert "uuid" in file_data + assert "download_url" in file_data + assert "thumbnail_url" in file_data + assert "view_url" in file_data + assert "created_at" in file_data + + # Security: Verify internal details are NOT exposed + assert "id" not in file_data + assert "filename" not in file_data + assert "minio_bucket" not in file_data + assert "minio_object_name" not in file_data + assert "thumbnail_minio_bucket" not in file_data + assert "thumbnail_minio_object_name" not in file_data + assert "attachable_id" not in file_data + assert "attachable_type" not in file_data + assert "uploaded_by" not in file_data + + +@pytest.mark.integration +def test_upload_pdf_to_card(client, auth_headers, test_card, test_pdf_file): + """Test uploading a PDF to a card""" + data = { + "file": (test_pdf_file, "test.pdf", "application/pdf"), + } + + response = client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 201 + file_data = response.get_json() + assert file_data["original_name"] == "test.pdf" + assert file_data["file_type"] == "pdf" # PDFProcessor returns "pdf" as file_type + # PDFs don't have thumbnails + assert file_data.get("thumbnail_url") is None + + +@pytest.mark.integration +def test_upload_file_to_comment(client, auth_headers, test_comment, test_image_file): + """Test uploading a file to a comment""" + data = { + "file": (test_image_file, "comment_image.png", "image/png"), + } + + response = client.post( + f"/api/comments/{test_comment.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 201 + file_data = response.get_json() + assert file_data["original_name"] == "comment_image.png" + assert file_data["file_type"] == "image" + + +@pytest.mark.integration +def test_get_card_attachments(client, auth_headers, test_card, test_image_file): + """Test getting all attachments for a card""" + # Upload a file first + data = {"file": (test_image_file, "test.png", "image/png")} + client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + + # Get attachments + response = client.get( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + ) + + assert response.status_code == 200 + attachments_data = response.get_json() + assert attachments_data["count"] == 1 + assert len(attachments_data["files"]) == 1 + assert attachments_data["files"][0]["original_name"] == "test.png" + # Verify only public fields are exposed + assert "id" not in attachments_data["files"][0] + assert "attachable_id" not in attachments_data["files"][0] + + +@pytest.mark.integration +def test_get_comment_attachments(client, auth_headers, test_comment, test_image_file): + """Test getting all attachments for a comment""" + # Upload a file first + data = {"file": (test_image_file, "test.png", "image/png")} + client.post( + f"/api/comments/{test_comment.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + + # Get attachments + response = client.get( + f"/api/comments/{test_comment.id}/attachments", + headers=auth_headers, + ) + + assert response.status_code == 200 + attachments_data = response.get_json() + assert attachments_data["count"] == 1 + assert len(attachments_data["files"]) == 1 + + +@pytest.mark.integration +def test_get_file_by_uuid(client, auth_headers, test_card, test_image_file): + """Test getting file info by UUID""" + # Upload a file first + data = {"file": (test_image_file, "test.png", "image/png")} + upload_response = client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + file_uuid = upload_response.get_json()["uuid"] + + # Get file by UUID + response = client.get( + f"/api/files/{file_uuid}", + headers=auth_headers, + ) + + assert response.status_code == 200 + file_data = response.get_json() + assert file_data["uuid"] == file_uuid + assert file_data["original_name"] == "test.png" + assert "download_url" in file_data + # Verify only public fields + assert "id" not in file_data + assert "attachable_id" not in file_data + + +@pytest.mark.integration +def test_delete_file(client, auth_headers, test_card, test_image_file): + """Test deleting a file""" + # Upload a file first + data = {"file": (test_image_file, "test.png", "image/png")} + upload_response = client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + file_uuid = upload_response.get_json()["uuid"] + + # Delete file + response = client.delete( + f"/api/files/{file_uuid}", + headers=auth_headers, + ) + + assert response.status_code == 200 + delete_data = response.get_json() + assert delete_data["message"] == "File deleted" + + # Verify file is deleted + get_response = client.get( + f"/api/files/{file_uuid}", + headers=auth_headers, + ) + assert get_response.status_code == 404 + + +@pytest.mark.integration +def test_download_file(client, auth_headers, test_card, test_image_file): + """Test downloading a file through proxy""" + # Upload a file first + data = {"file": (test_image_file, "test.png", "image/png")} + upload_response = client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + file_uuid = upload_response.get_json()["uuid"] + + # Download file + response = client.get( + f"/api/files/{file_uuid}/download", + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.content_type == "image/png" + assert "Content-Disposition" in response.headers + assert "test.png" in response.headers["Content-Disposition"] + + +@pytest.mark.integration +def test_view_file(client, auth_headers, test_card, test_image_file): + """Test viewing a file inline through proxy""" + # Upload a file first + data = {"file": (test_image_file, "test.png", "image/png")} + upload_response = client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + file_uuid = upload_response.get_json()["uuid"] + + # View file inline + response = client.get( + f"/api/files/{file_uuid}/view", + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.content_type == "image/png" + assert "inline" in response.headers["Content-Disposition"] + + +@pytest.mark.integration +def test_download_thumbnail(client, auth_headers, test_card, test_image_file): + """Test downloading a file thumbnail""" + # Upload an image file + data = {"file": (test_image_file, "test.png", "image/png")} + upload_response = client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + file_uuid = upload_response.get_json()["uuid"] + + # Download thumbnail + response = client.get( + f"/api/files/{file_uuid}/thumbnail", + headers=auth_headers, + ) + + assert response.status_code == 200 + assert response.content_type == "image/jpeg" + + +@pytest.mark.integration +def test_thumbnail_not_available_for_pdf( + client, auth_headers, test_card, test_pdf_file +): + """Test that PDF files don't have thumbnails""" + # Upload a PDF file + data = {"file": (test_pdf_file, "test.pdf", "application/pdf")} + upload_response = client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + file_uuid = upload_response.get_json()["uuid"] + + # Try to download thumbnail + response = client.get( + f"/api/files/{file_uuid}/thumbnail", + headers=auth_headers, + ) + + assert response.status_code == 404 + + +@pytest.mark.integration +def test_upload_without_authentication(client, test_card, test_image_file): + """Test uploading a file without authentication""" + data = {"file": (test_image_file, "test.png", "image/png")} + + response = client.post( + f"/api/cards/{test_card.id}/attachments", + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 401 + + +@pytest.mark.integration +def test_get_card_attachments_empty(client, auth_headers, test_card): + """Test getting attachments when card has none""" + response = client.get( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + ) + + assert response.status_code == 200 + attachments_data = response.get_json() + assert attachments_data["count"] == 0 + assert len(attachments_data["files"]) == 0 + + +@pytest.mark.integration +def test_upload_without_file(client, auth_headers, test_card): + """Test uploading without providing a file""" + data = {} + + response = client.post( + f"/api/cards/{test_card.id}/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 400 + + +@pytest.mark.integration +def test_upload_with_invalid_card_id(client, auth_headers, test_image_file): + """Test uploading to a non-existent card""" + data = {"file": (test_image_file, "test.png", "image/png")} + + response = client.post( + "/api/cards/99999/attachments", + headers=auth_headers, + data=data, + content_type="multipart/form-data", + ) + + assert response.status_code == 404 + + +@pytest.mark.integration +def test_get_file_with_invalid_uuid(client, auth_headers): + """Test getting a file with an invalid UUID""" + response = client.get( + "/api/files/invalid-uuid-12345", + headers=auth_headers, + ) + + assert response.status_code == 404 + + +@pytest.mark.integration +def test_delete_file_with_invalid_uuid(client, auth_headers): + """Test deleting a file with an invalid UUID""" + response = client.delete( + "/api/files/invalid-uuid-12345", + headers=auth_headers, + ) + + assert response.status_code == 404 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2138ae5..cfacf27 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -23,8 +23,30 @@ services: - "6381:6379" restart: unless-stopped + minio: + image: minio/minio:latest + container_name: kanban-app-minio + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} + volumes: + - minio-data:/data + networks: + - kanban-app-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + volumes: postgres-dev-data: + minio-data: networks: kanban-app-network: diff --git a/docker-compose.yml b/docker-compose.yml index 018d0cf..3c19411 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,27 @@ services: - crafting-shop-network restart: unless-stopped + minio: + image: minio/minio:latest + container_name: crafting-shop-minio + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} + volumes: + - minio-data:/data + networks: + - crafting-shop-network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + celery_worker: build: context: ./backend @@ -163,6 +184,7 @@ volumes: grafana-data: backend-data: celery-beat-data: + minio-data: networks: crafting-shop-network: diff --git a/frontend/src/components/CardAttachments.tsx b/frontend/src/components/CardAttachments.tsx new file mode 100644 index 0000000..277b549 --- /dev/null +++ b/frontend/src/components/CardAttachments.tsx @@ -0,0 +1,163 @@ +import { useFileAttachments } from '../hooks/useFileAttachments'; +import Trash2Icon from './icons/Trash2Icon'; +import FileIcon from './icons/FileIcon'; +import DownloadIcon from './icons/DownloadIcon'; +import EyeIcon from './icons/EyeIcon'; +import { SecureImage } from './SecureImage'; + +interface CardAttachmentsProps { + cardId?: number; +} + +export function CardAttachments({ cardId }: CardAttachmentsProps) { + const { attachments, uploadFile, deleteAttachment } = useFileAttachments(cardId); + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + await uploadFile(file); + } catch { + // Error already handled in hook with toast + } + + // Reset input + e.target.value = ''; + }; + + const handleDelete = async (fileUuid: string, fileName: string) => { + if (!window.confirm(`Are you sure you want to delete "${fileName}"?`)) { + return; + } + + await deleteAttachment(fileUuid); + }; + + const formatFileSize = (bytes: number) => { + 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]; + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }; + + const isImage = (mimeType: string) => mimeType.startsWith('image/'); + + return ( +
+
+

+ + + + Attachments +

+
+ {attachments.length} files + +
+
+ + {attachments.length === 0 ? ( +
+

No attachments yet

+

Upload images or PDFs to this card

+
+ ) : ( +
+ {attachments.map((attachment) => ( +
+
+ {/* Thumbnail or file icon */} +
+ {isImage(attachment.mime_type) && attachment.thumbnail_url ? ( + + ) : ( +
+ + + +
+ )} +
+ + {/* File info */} +
+
+
+

{attachment.original_name}

+

+ {formatFileSize(attachment.file_size)} • {formatDate(attachment.created_at)} +

+
+ + {/* Action buttons */} +
+ {isImage(attachment.mime_type) && attachment.view_url && ( + + + + + + )} + {attachment.download_url && ( + + + + + + )} + +
+
+
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/SecureImage.tsx b/frontend/src/components/SecureImage.tsx new file mode 100644 index 0000000..591df6e --- /dev/null +++ b/frontend/src/components/SecureImage.tsx @@ -0,0 +1,46 @@ +import { useSecureImage } from '../hooks/useSecureImage'; + +interface SecureImageProps { + url: string; + alt: string; + className?: string; + fallback?: React.ReactNode; +} + +export function SecureImage({ url, alt, className = '', fallback }: SecureImageProps) { + const { imageUrl, loading, error } = useSecureImage(url); + + // Show loading state + if (loading) { + return
; + } + + // Show error or fallback + if (error || !imageUrl) { + return fallback ? ( + <>{fallback} + ) : ( +
+ + + + + +
+ ); + } + + // Show image + return {alt}; +} diff --git a/frontend/src/components/icons/DownloadIcon.tsx b/frontend/src/components/icons/DownloadIcon.tsx new file mode 100644 index 0000000..b5ad3db --- /dev/null +++ b/frontend/src/components/icons/DownloadIcon.tsx @@ -0,0 +1,19 @@ +const DownloadIcon = () => ( + + + + + +); + +export default DownloadIcon; diff --git a/frontend/src/components/icons/EyeIcon.tsx b/frontend/src/components/icons/EyeIcon.tsx new file mode 100644 index 0000000..54a5362 --- /dev/null +++ b/frontend/src/components/icons/EyeIcon.tsx @@ -0,0 +1,18 @@ +const EyeIcon = () => ( + + + + +); + +export default EyeIcon; diff --git a/frontend/src/components/icons/FileIcon.tsx b/frontend/src/components/icons/FileIcon.tsx new file mode 100644 index 0000000..87b9b1e --- /dev/null +++ b/frontend/src/components/icons/FileIcon.tsx @@ -0,0 +1,18 @@ +const FileIcon = () => ( + + + + +); + +export default FileIcon; diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index b61923a..3deb170 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -267,6 +267,38 @@ export function useApi() { deleteComment: async (id: number): Promise => { await api.delete(`/comments/${id}`); }, + + // File Attachments + getCardAttachments: async (cardId: number): Promise => { + const response = await api.get(`/cards/${cardId}/attachments`); + return response.data; + }, + uploadCardAttachment: async ( + cardId: number, + file: File, + entityType: string = 'Card' + ): Promise => { + const formData = new FormData(); + formData.append('file', file); + formData.append('entity_type', entityType); + formData.append('entity_id', cardId.toString()); + + const response = await api.post(`/cards/${cardId}/attachments`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; + }, + deleteFile: async (fileUuid: string): Promise => { + await api.delete(`/files/${fileUuid}`); + }, + getSecureImage: async (url: string): Promise => { + const response = await api.get(url, { + responseType: 'blob', + }); + return response.data; + }, }), [] ); diff --git a/frontend/src/hooks/useFileAttachments.ts b/frontend/src/hooks/useFileAttachments.ts new file mode 100644 index 0000000..cce37eb --- /dev/null +++ b/frontend/src/hooks/useFileAttachments.ts @@ -0,0 +1,129 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; +import { useLoader } from '../context/loaders/useLoader'; +import { useToast } from '../context/toasts/useToast'; +import { FileAttachment } from '../types/kanban'; + +export function useFileAttachments(cardId?: number) { + const [attachments, setAttachments] = useState([]); + const [error, setError] = useState(null); + + const { getCardAttachments, uploadCardAttachment, deleteFile } = useApi(); + const { withLoader } = useLoader(); + const { addNotification } = useToast(); + + const fetchAttachments = useCallback(async () => { + if (!cardId) { + return []; + } + try { + setError(null); + const data = await withLoader(() => getCardAttachments(cardId), 'Loading attachments...'); + setAttachments(data.files || []); + return data.files || []; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load attachments'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Error Loading Attachments', + message: errorMessage, + duration: 5000, + }); + return []; + } + }, [getCardAttachments, cardId, withLoader, addNotification]); + + const uploadFile = useCallback( + async (file: File) => { + if (!cardId) { + return null; + } + try { + setError(null); + + // Validate file size (10MB for images, 50MB for documents) + const maxSize = file.type.startsWith('image/') ? 10 * 1024 * 1024 : 50 * 1024 * 1024; + if (file.size > maxSize) { + const maxSizeMB = file.type.startsWith('image/') ? '10MB' : '50MB'; + throw new Error(`File size exceeds ${maxSizeMB} limit`); + } + + const data = await withLoader( + () => uploadCardAttachment(cardId, file, 'Card'), + 'Uploading file...' + ); + + // Add new attachment to state + setAttachments((prev) => [...prev, data]); + + addNotification({ + type: 'success', + title: 'File Uploaded', + message: `Successfully uploaded ${file.name}`, + duration: 3000, + }); + + return data; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to upload file'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Upload Failed', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [uploadCardAttachment, cardId, withLoader, addNotification] + ); + + const deleteAttachment = useCallback( + async (fileUuid: string) => { + try { + setError(null); + await withLoader(() => deleteFile(fileUuid), 'Deleting file...'); + + // Remove attachment from state + setAttachments((prev) => prev.filter((a) => a.uuid !== fileUuid)); + + addNotification({ + type: 'success', + title: 'File Deleted', + message: 'File deleted successfully', + duration: 3000, + }); + + return true; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to delete file'; + setError(err instanceof Error ? err : new Error(errorMessage)); + addNotification({ + type: 'error', + title: 'Delete Failed', + message: errorMessage, + duration: 5000, + }); + throw err; + } + }, + [deleteFile, withLoader, addNotification] + ); + + useEffect(() => { + if (cardId) { + fetchAttachments(); + } + }, [cardId, fetchAttachments]); + + return { + attachments, + error, // For debugging, not for UI display + loading: false, // Loading is handled by global loader + refetch: fetchAttachments, + uploadFile, + deleteAttachment, + }; +} diff --git a/frontend/src/hooks/useSecureImage.ts b/frontend/src/hooks/useSecureImage.ts new file mode 100644 index 0000000..9dd90b7 --- /dev/null +++ b/frontend/src/hooks/useSecureImage.ts @@ -0,0 +1,51 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useApi } from './useApi'; + +interface UseSecureImageReturn { + imageUrl: string | null; + loading: boolean; + error: Error | null; +} + +export function useSecureImage(url: string): UseSecureImageReturn { + const [imageUrl, setImageUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const { getSecureImage } = useApi(); + + const fetchImage = useCallback(async () => { + if (!url) { + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + // Fetch image with authentication + const blob = await getSecureImage(url); + + // Create blob URL + const blobUrl = URL.createObjectURL(blob); + setImageUrl(blobUrl); + } catch (err) { + console.error('Error fetching image:', err); + const errorMessage = err instanceof Error ? err.message : 'Failed to load image'; + setError(new Error(errorMessage)); + } finally { + setLoading(false); + } + }, [url, getSecureImage]); + + useEffect(() => { + fetchImage(); + }, [fetchImage]); + + return { + imageUrl, + loading, + error, // For debugging + }; +} diff --git a/frontend/src/pages/BoardDetail.tsx b/frontend/src/pages/BoardDetail.tsx index d2473cf..a49d6ee 100644 --- a/frontend/src/pages/BoardDetail.tsx +++ b/frontend/src/pages/BoardDetail.tsx @@ -244,7 +244,7 @@ export function BoardDetail() { -
+
(); @@ -84,7 +86,7 @@ export function CardDetail() { }; return ( -
+
+ +
-
+
); } diff --git a/frontend/src/types/kanban.ts b/frontend/src/types/kanban.ts index dfaa5ac..3b98be6 100644 --- a/frontend/src/types/kanban.ts +++ b/frontend/src/types/kanban.ts @@ -183,3 +183,16 @@ export interface User { first_name?: string; last_name?: string; } + +// File Attachment types +export interface FileAttachment { + uuid: string; + original_name: string; + file_type: string; + file_size: number; + mime_type: string; + created_at: string; + download_url: string; + thumbnail_url: string | null; + view_url: string; +}