From c7d602cfa51f05c46ba537a0fe408550fa173c7b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 20 Mar 2026 03:15:37 +0000 Subject: [PATCH] feat(evidence): add S3 storage service and file_uploads model Co-Authored-By: Claude Opus 4.6 (1M context) --- .../49150866ae44_add_file_uploads_table.py | 46 ++++++++++ backend/app/core/config.py | 7 ++ backend/app/models/__init__.py | 2 + backend/app/models/file_upload.py | 32 +++++++ backend/app/services/storage_service.py | 86 +++++++++++++++++++ backend/requirements.txt | 3 + 6 files changed, 176 insertions(+) create mode 100644 backend/alembic/versions/49150866ae44_add_file_uploads_table.py create mode 100644 backend/app/models/file_upload.py create mode 100644 backend/app/services/storage_service.py diff --git a/backend/alembic/versions/49150866ae44_add_file_uploads_table.py b/backend/alembic/versions/49150866ae44_add_file_uploads_table.py new file mode 100644 index 00000000..8bce6d69 --- /dev/null +++ b/backend/alembic/versions/49150866ae44_add_file_uploads_table.py @@ -0,0 +1,46 @@ +"""add file_uploads table + +Revision ID: 49150866ae44 +Revises: e0d382f083d4 +Create Date: 2026-03-20 03:15:10.227797 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '49150866ae44' +down_revision: Union[str, None] = 'e0d382f083d4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'file_uploads', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('account_id', sa.UUID(), nullable=False), + sa.Column('uploaded_by', sa.UUID(), nullable=False), + sa.Column('session_id', sa.UUID(), nullable=True), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('content_type', sa.String(length=100), nullable=False), + sa.Column('size_bytes', sa.Integer(), nullable=False), + sa.Column('storage_key', sa.String(length=500), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['session_id'], ['ai_sessions.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('storage_key'), + ) + op.create_index(op.f('ix_file_uploads_account_id'), 'file_uploads', ['account_id'], unique=False) + op.create_index(op.f('ix_file_uploads_session_id'), 'file_uploads', ['session_id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_file_uploads_session_id'), table_name='file_uploads') + op.drop_index(op.f('ix_file_uploads_account_id'), table_name='file_uploads') + op.drop_table('file_uploads') diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ea17db51..955543ef 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -124,6 +124,13 @@ class Settings(BaseSettings): """Check if any AI provider is configured.""" return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None + # Object Storage (Railway S3-compatible) + STORAGE_ENDPOINT: str | None = None + STORAGE_ACCESS_KEY: str | None = None + STORAGE_SECRET_KEY: str | None = None + STORAGE_BUCKET_NAME: str = "resolutionflow-uploads" + STORAGE_REGION: str = "us-east-1" + # ConnectWise PSA Integration # CW_CLIENT_ID is a product-level GUID registered at developer.connectwise.com # All MSP customers share this single clientId — it identifies ResolutionFlow as the integration diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1a4eb3b6..a01c04c4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -47,6 +47,7 @@ from .notification_config import NotificationConfig from .notification_log import NotificationLog from .notification import Notification from .psa_activity_log import PsaActivityLog +from .file_upload import FileUpload __all__ = [ "User", @@ -108,4 +109,5 @@ __all__ = [ "NotificationLog", "Notification", "PsaActivityLog", + "FileUpload", ] diff --git a/backend/app/models/file_upload.py b/backend/app/models/file_upload.py new file mode 100644 index 00000000..4c447326 --- /dev/null +++ b/backend/app/models/file_upload.py @@ -0,0 +1,32 @@ +"""File upload metadata — tracks files stored in S3-compatible object storage.""" +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import String, Integer, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID + +from app.core.database import Base + + +class FileUpload(Base): + __tablename__ = "file_uploads" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True + ) + uploaded_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False + ) + session_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="SET NULL"), nullable=True, index=True + ) + filename: Mapped[str] = mapped_column(String(255), nullable=False) + content_type: Mapped[str] = mapped_column(String(100), nullable=False) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + storage_key: Mapped[str] = mapped_column(String(500), nullable=False, unique=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) diff --git a/backend/app/services/storage_service.py b/backend/app/services/storage_service.py new file mode 100644 index 00000000..d6e9399c --- /dev/null +++ b/backend/app/services/storage_service.py @@ -0,0 +1,86 @@ +"""S3-compatible object storage service for file uploads.""" +import logging +import uuid +from io import BytesIO + +import boto3 +from botocore.config import Config as BotoConfig +from botocore.exceptions import ClientError + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"} +ALLOWED_TEXT_TYPES = {"text/plain", "text/csv", "application/octet-stream"} +ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_TEXT_TYPES + +MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB +MAX_TEXT_SIZE = 1 * 1024 * 1024 # 1MB +MAX_FILES_PER_SESSION = 20 +MAX_BYTES_PER_SESSION = 50 * 1024 * 1024 # 50MB + +PRESIGNED_URL_EXPIRY = 3600 # 1 hour + + +def _get_client(): + """Get S3 client configured for Railway Object Storage.""" + if not settings.STORAGE_ENDPOINT: + raise RuntimeError("Object storage not configured (STORAGE_ENDPOINT missing)") + return boto3.client( + "s3", + endpoint_url=settings.STORAGE_ENDPOINT, + aws_access_key_id=settings.STORAGE_ACCESS_KEY, + aws_secret_access_key=settings.STORAGE_SECRET_KEY, + region_name=settings.STORAGE_REGION, + config=BotoConfig(signature_version="s3v4"), + ) + + +def validate_upload(content_type: str, size_bytes: int) -> str | None: + """Validate file type and size. Returns error message or None.""" + if content_type not in ALLOWED_TYPES: + return f"File type {content_type} not allowed" + max_size = MAX_IMAGE_SIZE if content_type in ALLOWED_IMAGE_TYPES else MAX_TEXT_SIZE + if size_bytes > max_size: + return f"File too large ({size_bytes} bytes, max {max_size})" + return None + + +async def upload_file( + file_data: bytes, + filename: str, + content_type: str, + account_id: str, +) -> str: + """Upload file to S3, returns the storage key.""" + ext = filename.rsplit(".", 1)[-1] if "." in filename else "bin" + storage_key = f"uploads/{account_id}/{uuid.uuid4()}.{ext}" + + client = _get_client() + client.upload_fileobj( + BytesIO(file_data), + settings.STORAGE_BUCKET_NAME, + storage_key, + ExtraArgs={"ContentType": content_type}, + ) + return storage_key + + +def get_presigned_url(storage_key: str) -> str: + """Generate a time-limited presigned URL for downloading a file.""" + client = _get_client() + return client.generate_presigned_url( + "get_object", + Params={"Bucket": settings.STORAGE_BUCKET_NAME, "Key": storage_key}, + ExpiresIn=PRESIGNED_URL_EXPIRY, + ) + + +async def delete_file(storage_key: str) -> None: + """Delete a file from S3.""" + try: + client = _get_client() + client.delete_object(Bucket=settings.STORAGE_BUCKET_NAME, Key=storage_key) + except ClientError: + logger.warning(f"Failed to delete S3 object: {storage_key}") diff --git a/backend/requirements.txt b/backend/requirements.txt index b884f6e7..634f9fee 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -51,3 +51,6 @@ python-dotenv==1.0.1 croniter>=2.0.0 pytz>=2024.1 apscheduler>=3.10.4 + +# Object Storage +boto3>=1.34.0