diff --git a/FILES-ARCHITECTURE.md b/FILES-ARCHITECTURE.md index 1f54b00..26b26b4 100644 --- a/FILES-ARCHITECTURE.md +++ b/FILES-ARCHITECTURE.md @@ -1,398 +1,1035 @@ -# Архитектура файловой системы Team Board +# Files Architecture — Team Board -## Обзор - -Team Board использует гибридную архитектуру для работы с файлами: -- **Project Files** — файлы проекта, управляемые через файловую систему -- **Attachments** — вложения к сообщениям и задачам, управляемые через БД + FS - -## Структура директорий - -``` -${TEAM_BOARD_WORKSPACE}/ -├── / -│ ├── files/ # Project files (FS-only) -│ │ ├── documents/ -│ │ ├── images/ -│ │ ├── code/ -│ │ └── misc/ -│ └── attachments/ # Message/task attachments (DB + FS) -│ ├── / -│ │ └── original_file.ext -│ ├── / -│ │ ├── original_file.ext -│ │ └── .thumbnails/ -│ │ ├── 150x150.jpg -│ │ └── 300x300.jpg -│ └── ... -└── .quotas # Файл с квотами проектов -``` - -### Переменные окружения - -```bash -TEAM_BOARD_WORKSPACE=/opt/team-board/workspace # Корневая директория -``` - -## Типы файлов - -### 1. Project Files -**Назначение**: Рабочие файлы проекта, которые пользователи могут добавлять вручную - -**Характеристики**: -- Хранение: только файловая система -- Обнаружение: directory listing при запросе API -- Управление: пользователь может добавить файлы руками в `workspace//files/` -- Структура: свободная, пользователь организует как хочет -- Метаданные: извлекаются на лету (размер, дата модификации, MIME-type) - -### 2. Attachments -**Назначение**: Вложения к сообщениям в чате и задачам - -**Характеристики**: -- Хранение: PostgreSQL (метаданные) + файловая система (содержимое) -- Управление: только через API -- Именование: UUID-based, оригинальное имя в БД -- Привязка: к message_id или task_id -- Превью: генерация thumbnail'ов для изображений - -## Модели базы данных - -### Таблица attachments - -```sql -CREATE TABLE attachments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - filename VARCHAR(255) NOT NULL, -- Оригинальное имя файла - content_type VARCHAR(100) NOT NULL, -- MIME-type - file_size BIGINT NOT NULL, -- Размер в байтах - file_hash SHA256 NOT NULL, -- SHA-256 хеш файла - storage_path VARCHAR(500) NOT NULL, -- Путь в FS (относительно workspace) - - -- Привязки - project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - message_id UUID REFERENCES messages(id) ON DELETE CASCADE, - task_id UUID REFERENCES tasks(id) ON DELETE CASCADE, - - -- Превью - has_thumbnail BOOLEAN DEFAULT FALSE, - thumbnail_sizes JSONB, -- {"150x150": "path", "300x300": "path"} - - -- Аудит - uploaded_by UUID NOT NULL REFERENCES users(id), - uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - - -- Ограничения - CONSTRAINT attachment_belongs_to_something - CHECK (message_id IS NOT NULL OR task_id IS NOT NULL), - CONSTRAINT valid_file_size - CHECK (file_size > 0 AND file_size <= 52428800) -- 50MB limit -); - -CREATE INDEX idx_attachments_project_id ON attachments(project_id); -CREATE INDEX idx_attachments_message_id ON attachments(message_id); -CREATE INDEX idx_attachments_task_id ON attachments(task_id); -CREATE INDEX idx_attachments_hash ON attachments(file_hash); -``` - -### Таблица project_quotas - -```sql -CREATE TABLE project_quotas ( - project_id UUID PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, - - -- Лимиты - max_total_size BIGINT DEFAULT 1073741824, -- 1GB по умолчанию - max_files_count INTEGER DEFAULT 1000, - - -- Текущее использование - current_files_size BIGINT DEFAULT 0, -- Project files + attachments - current_attachments_size BIGINT DEFAULT 0, -- Только attachments - current_files_count INTEGER DEFAULT 0, - - -- Обновления - last_scan_at TIMESTAMP WITH TIME ZONE, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); -``` - -## REST API Endpoints - -### Project Files - -#### `GET /api/v1/projects/{project_id}/files` -Список файлов проекта (directory scan) - -**Параметры запроса**: -- `path` (optional) — подпапка, по умолчанию "/" -- `recursive` (optional) — рекурсивный обход, по умолчанию false - -**Ответ**: -```json -{ - "path": "/documents", - "files": [ - { - "name": "spec.md", - "path": "/documents/spec.md", - "size": 15420, - "mime_type": "text/markdown", - "modified_at": "2024-01-15T10:30:00Z", - "is_directory": false - } - ], - "quota": { - "used": 245760000, - "total": 1073741824, - "files_count": 156 - } -} -``` - -#### `POST /api/v1/projects/{project_id}/files/upload` -Загрузка файла в проект - -**Тело запроса**: multipart/form-data -- `file` — файл для загрузки -- `path` (optional) — путь размещения, по умолчанию "/" - -**Ответ**: -```json -{ - "success": true, - "file": { - "name": "document.pdf", - "path": "/documents/document.pdf", - "size": 1540000 - } -} -``` - -#### `GET /api/v1/projects/{project_id}/files/download` -Скачивание файла проекта - -**Параметры запроса**: -- `path` — путь к файлу - -**Ответ**: binary data с соответствующими headers - -#### `DELETE /api/v1/projects/{project_id}/files` -Удаление файла проекта - -**Параметры запроса**: -- `path` — путь к файлу - -### Attachments - -#### `POST /api/v1/projects/{project_id}/attachments` -Загрузка вложения - -**Тело запроса**: multipart/form-data -- `file` — файл для загрузки -- `message_id` (optional) — ID сообщения -- `task_id` (optional) — ID задачи - -**Ответ**: -```json -{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "filename": "screenshot.png", - "content_type": "image/png", - "file_size": 245760, - "has_thumbnail": true, - "download_url": "/api/v1/attachments/550e8400-e29b-41d4-a716-446655440000/download", - "thumbnail_url": "/api/v1/attachments/550e8400-e29b-41d4-a716-446655440000/thumbnail/150x150" -} -``` - -#### `GET /api/v1/projects/{project_id}/attachments` -Список вложений проекта - -**Параметры запроса**: -- `message_id` (optional) — фильтр по сообщению -- `task_id` (optional) — фильтр по задаче -- `limit` (optional, default=50) — количество записей -- `offset` (optional, default=0) — смещение - -#### `GET /api/v1/attachments/{attachment_id}/download` -Скачивание вложения - -#### `GET /api/v1/attachments/{attachment_id}/thumbnail/{size}` -Получение превью (sizes: 150x150, 300x300) - -#### `DELETE /api/v1/attachments/{attachment_id}` -Удаление вложения - -## MCP Tools для агентов - -### upload_file -Загрузка файла в проект - -**Параметры**: -- `project_id: str` — ID проекта -- `file_path: str` — локальный путь к файлу -- `target_path: str` — путь размещения в проекте (optional) -- `file_type: "project" | "attachment"` — тип файла -- `message_id: str` (optional, для attachment) -- `task_id: str` (optional, для attachment) - -### download_file -Скачивание файла из проекта - -**Параметры**: -- `project_id: str` — ID проекта -- `file_path: str` — путь к файлу в проекте (для project files) -- `attachment_id: str` — ID вложения (для attachments) -- `local_path: str` — куда сохранить локально - -### list_files -Список файлов проекта - -**Параметры**: -- `project_id: str` — ID проекта -- `file_type: "project" | "attachment" | "all"` — тип файлов -- `path: str` (optional) — подпапка для project files -- `recursive: bool` (optional) — рекурсивный обход - -## Лимиты и квоты - -### Лимиты на файл -- **Максимальный размер**: 50MB (52,428,800 байт) -- **Поддерживаемые типы**: все MIME-types, проверка по расширению - -### Квоты на проект -- **Общий размер по умолчанию**: 1GB -- **Максимальное количество файлов**: 1000 -- **Настройка**: через admin панель или ENV переменные - -### Проверка квот -```python -def check_project_quota(project_id: str, additional_size: int) -> bool: - quota = get_project_quota(project_id) - current_usage = calculate_project_usage(project_id) - - return (current_usage.total_size + additional_size) <= quota.max_total_size -``` - -## Превью изображений - -### Поддерживаемые форматы -- PNG, JPEG, GIF, WebP -- SVG (конвертация в PNG) - -### Размеры превью -- `150x150` — мелкая иконка -- `300x300` — средний размер для карточек - -### Генерация -- **Lazy loading**: генерация при первом запросе -- **Кэширование**: сохранение в подпапке `.thumbnails/` -- **Библиотека**: Pillow (Python) или Sharp (Node.js) - -```python -async def generate_thumbnail(attachment_id: str, size: str) -> str: - """Генерирует превью для изображения""" - attachment = await get_attachment(attachment_id) - if not attachment.is_image(): - raise ValueError("Not an image") - - thumbnail_path = f"{attachment.storage_path}/.thumbnails/{size}.jpg" - if os.path.exists(thumbnail_path): - return thumbnail_path - - # Генерируем превью - with Image.open(attachment.full_path) as img: - img.thumbnail((int(size.split('x')[0]), int(size.split('x')[1]))) - os.makedirs(os.path.dirname(thumbnail_path), exist_ok=True) - img.save(thumbnail_path, "JPEG", quality=85) - - # Обновляем БД - await update_attachment_thumbnail(attachment_id, size, thumbnail_path) - - return thumbnail_path -``` - -## Примеры использования - -### Пользователь добавляет файл вручную -```bash -# Пользователь копирует файл -cp /home/user/document.pdf ${TEAM_BOARD_WORKSPACE}/my-project/files/docs/ - -# API автоматически увидит файл при следующем запросе -curl -X GET "https://teamboard.com/api/v1/projects/123/files" -``` - -### Агент загружает вложение -```python -# MCP tool call -result = await upload_file( - project_id="123", - file_path="/tmp/screenshot.png", - file_type="attachment", - message_id="456" -) -``` - -### Получение превью -```bash -curl -X GET "https://teamboard.com/api/v1/attachments/550e8400-e29b-41d4-a716-446655440000/thumbnail/150x150" \ - -H "Authorization: Bearer TOKEN" \ - --output thumbnail.jpg -``` - -## Безопасность - -### Валидация файлов -- Проверка MIME-type по содержимому файла (magic bytes) -- Запрет исполняемых файлов (.exe, .sh, .bat) -- Сканирование на вирусы (ClamAV integration) - -### Изоляция -- Каждый проект изолирован в своей директории -- Запрет обхода пути (path traversal protection) -- Валидация имен файлов (запрет специальных символов) - -### Доступ -- Аутентификация через JWT токены -- Авторизация на уровне проекта -- Rate limiting для upload операций - -## Мониторинг - -### Метрики -- Использование дискового пространства по проектам -- Скорость загрузки/скачивания файлов -- Количество генерированных превью -- Ошибки валидации файлов - -### Логирование -- Все операции с файлами (upload, download, delete) -- Превышение квот -- Ошибки генерации превью -- Подозрительные файлы +**Версия:** 1.0 +**Дата:** 2026-02-24 +**Автор:** Subagent (Research & Design) --- -## Техническая реализация +## 1. Обзор + +Team Board нуждается в файловой системе для двух типов файлов: +1. **Project Files** — файлы проекта (документация, код, ресурсы) +2. **Attachments** — файлы, прикреплённые к сообщениям и задачам + +**Ключевые принципы:** +- Файлы хранятся на диске в настраиваемом workspace +- Структура папок по проектам: `workspace//` +- Файлы, добавленные вручную, автоматически индексируются +- Агенты имеют полный доступ через MCP tools +- Единый API для всех операций с файлами + +--- + +## 2. Структура директорий + +### 2.1 Общая структура workspace -### FastAPI структура ``` -app/ -├── routers/ -│ ├── project_files.py # /api/v1/projects/{id}/files/* -│ └── attachments.py # /api/v1/attachments/* -├── services/ -│ ├── file_storage.py # Работа с FS -│ ├── thumbnail.py # Генерация превью -│ └── quota.py # Управление квотами -└── models/ - ├── attachment.py # SQLAlchemy модели - └── quota.py +/data/team-board-workspace/ +├── projects/ # Корневая папка проектов +│ ├── / # Папка проекта (по slug) +│ │ ├── files/ # Проектные файлы (ручное добавление) +│ │ │ ├── docs/ # Документация +│ │ │ ├── assets/ # Ресурсы (изображения, шрифты) +│ │ │ ├── configs/ # Конфигурационные файлы +│ │ │ └── misc/ # Прочие файлы +│ │ ├── attachments/ # Файлы из сообщений/задач +│ │ │ ├── messages/ # По ID сообщения +│ │ │ │ ├── / +│ │ │ │ │ ├── document.pdf +│ │ │ │ │ └── screenshot.png +│ │ │ └── tasks/ # По ID задачи +│ │ │ └── / +│ │ │ ├── requirements.md +│ │ │ └── mockup.png +│ │ └── .meta/ # Метаданные (индекс файлов) +│ │ ├── files.index.json # Индекс проектных файлов +│ │ └── sync.log # Лог синхронизации +│ └── shared/ # Общие файлы (не привязаны к проекту) +│ ├── avatars/ # Аватары пользователей +│ ├── templates/ # Шаблоны документов +│ └── tmp/ # Временные файлы +├── .global/ # Глобальные настройки +│ ├── config.json # Конфигурация файловой системы +│ ├── mime-types.json # Настройки типов файлов +│ └── quotas.json # Квоты пользователей/проектов +└── trash/ # Корзина (soft delete) + └── / + └── ``` -### Background tasks -- Очистка неиспользуемых файлов -- Пересчет квот проектов -- Генерация превью в фоне -- Сжатие старых превью \ No newline at end of file +### 2.2 Пример для проекта "team-board" + +``` +/data/team-board-workspace/projects/team-board/ +├── files/ # Проектные файлы +│ ├── docs/ +│ │ ├── README.md # Добавлен вручную +│ │ ├── API.md # Добавлен вручную +│ │ └── diagrams/ +│ │ └── architecture.png +│ ├── configs/ +│ │ ├── docker-compose.yml +│ │ └── .env.example +│ └── assets/ +│ ├── logo.png +│ └── icons/ +├── attachments/ # Из сообщений/задач +│ ├── messages/ +│ │ └── 550e8400-e29b-41d4-a716-446655440000/ +│ │ ├── error-screenshot.png # Attachment к сообщению +│ │ └── logs.txt +│ └── tasks/ +│ └── 550e8400-e29b-41d4-a716-446655440001/ +│ └── technical-spec.pdf # Attachment к задаче +└── .meta/ + ├── files.index.json # Индекс проектных файлов + └── sync.log +``` + +### 2.3 Naming conventions + +**Проекты:** `` (латиница, дефисы) +**Файлы:** Исходные имена сохраняются, конфликты решаются суффиксами +**Папки attachments:** UUID сообщения/задачи +**Временные файлы:** UUID + timestamp + +--- + +## 3. Модели базы данных + +### 3.1 Расширение существующей модели Attachment + +```python +# Существующая модель (для attachments) +class Attachment(Base): + """File attachment to message or task comment.""" + __tablename__ = "attachments" + + message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("messages.id"), nullable=False) + filename: Mapped[str] = mapped_column(String(500), nullable=False) + mime_type: Mapped[str | None] = mapped_column(String(100)) + size: Mapped[int] = mapped_column(Integer, default=0) # bytes + storage_path: Mapped[str] = mapped_column(String(1000), nullable=False) # относительный путь + + message: Mapped["Message"] = relationship(back_populates="attachments") +``` + +### 3.2 Новая модель для проектных файлов + +```python +class ProjectFile(Base): + """File in project files/ directory — manually added or uploaded via API.""" + __tablename__ = "project_files" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) + + # Файл + filename: Mapped[str] = mapped_column(String(500), nullable=False) # исходное имя + relative_path: Mapped[str] = mapped_column(String(1000), nullable=False) # относительно project/files/ + mime_type: Mapped[str | None] = mapped_column(String(100)) + size: Mapped[int] = mapped_column(Integer, default=0) # bytes + checksum: Mapped[str | None] = mapped_column(String(64)) # SHA256 for deduplication + + # Метаданные + source: Mapped[str] = mapped_column(String(20), default="manual") # manual | api | agent + uploaded_by: Mapped[str | None] = mapped_column(String(255)) # member slug + description: Mapped[str | None] = mapped_column(Text) + tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # для категоризации + + # Флаги + is_indexed: Mapped[bool] = mapped_column(Boolean, default=True) # виден в API + is_searchable: Mapped[bool] = mapped_column(Boolean, default=True) # полнотекстовый поиск + + # Временные метки + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + indexed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # когда последний раз сканировался + + # Связи + project: Mapped["Project"] = relationship(back_populates="files") +``` + +### 3.3 Обновление модели Project + +```python +class Project(Base): + # ... существующие поля ... + + # Новая связь + files: Mapped[list["ProjectFile"]] = relationship(back_populates="project", cascade="all, delete-orphan") +``` + +### 3.4 Конфигурация файловой системы + +```python +class FileSystemConfig(Base): + """Global file system configuration.""" + __tablename__ = "fs_config" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # Пути + workspace_path: Mapped[str] = mapped_column(String(1000), nullable=False) # /data/team-board-workspace + + # Лимиты + max_file_size_mb: Mapped[int] = mapped_column(Integer, default=100) # 100MB + max_files_per_project: Mapped[int] = mapped_column(Integer, default=1000) + max_total_size_gb: Mapped[int] = mapped_column(Integer, default=10) # 10GB на проект + + # Разрешённые типы + allowed_extensions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # .pdf, .md, .png + blocked_extensions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # .exe, .bat + + # Автоматизация + auto_scan_interval: Mapped[int] = mapped_column(Integer, default=300) # секунды (5 минут) + enable_thumbnails: Mapped[bool] = mapped_column(Boolean, default=True) + enable_text_extraction: Mapped[bool] = mapped_column(Boolean, default=True) # для поиска + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), onupdate=func.now()) +``` + +--- + +## 4. REST API Endpoints + +### 4.1 Project Files API + +```python +# GET /api/projects/{project_slug}/files +async def list_project_files( + project_slug: str, + path: Optional[str] = Query(None), # фильтр по пути + tags: Optional[List[str]] = Query(None), # фильтр по тегам + mime_type: Optional[str] = Query(None), # фильтр по типу + search: Optional[str] = Query(None), # поиск в содержимом + limit: int = Query(50, le=200), + offset: int = Query(0), + db: AsyncSession = Depends(get_db) +) -> List[ProjectFileOut] + +# POST /api/projects/{project_slug}/files/upload +async def upload_project_file( + project_slug: str, + file: UploadFile, + path: Optional[str] = Form(None), # подпапка в files/ + description: Optional[str] = Form(None), + tags: Optional[str] = Form(None), # JSON array + member_slug: str = Depends(get_current_member), + db: AsyncSession = Depends(get_db) +) -> ProjectFileOut + +# GET /api/projects/{project_slug}/files/{file_id} +async def get_project_file( + project_slug: str, + file_id: str, + db: AsyncSession = Depends(get_db) +) -> ProjectFileOut + +# GET /api/projects/{project_slug}/files/{file_id}/download +async def download_project_file( + project_slug: str, + file_id: str, + db: AsyncSession = Depends(get_db) +) -> FileResponse + +# PUT /api/projects/{project_slug}/files/{file_id} +async def update_project_file( + project_slug: str, + file_id: str, + req: ProjectFileUpdate, + db: AsyncSession = Depends(get_db) +) -> ProjectFileOut + +# DELETE /api/projects/{project_slug}/files/{file_id} +async def delete_project_file( + project_slug: str, + file_id: str, + db: AsyncSession = Depends(get_db) +) -> Dict[str, bool] + +# POST /api/projects/{project_slug}/files/scan +async def scan_project_files( + project_slug: str, + db: AsyncSession = Depends(get_db) +) -> Dict[str, Any] # статистика сканирования +``` + +### 4.2 Attachments API (расширение существующего) + +```python +# POST /api/messages/{message_id}/attachments +async def upload_message_attachment( + message_id: str, + file: UploadFile, + db: AsyncSession = Depends(get_db) +) -> AttachmentOut + +# GET /api/messages/{message_id}/attachments +async def list_message_attachments( + message_id: str, + db: AsyncSession = Depends(get_db) +) -> List[AttachmentOut] + +# GET /api/attachments/{attachment_id}/download +async def download_attachment( + attachment_id: str, + db: AsyncSession = Depends(get_db) +) -> FileResponse + +# DELETE /api/attachments/{attachment_id} +async def delete_attachment( + attachment_id: str, + db: AsyncSession = Depends(get_db) +) -> Dict[str, bool] + +# POST /api/tasks/{task_id}/attachments +async def upload_task_attachment( + task_id: str, + file: UploadFile, + db: AsyncSession = Depends(get_db) +) -> AttachmentOut +``` + +### 4.3 File System Management API + +```python +# GET /api/admin/files/config +async def get_fs_config() -> FileSystemConfigOut + +# PUT /api/admin/files/config +async def update_fs_config(req: FileSystemConfigUpdate) -> FileSystemConfigOut + +# POST /api/admin/files/scan-all +async def scan_all_projects() -> Dict[str, Any] # глобальное сканирование + +# GET /api/admin/files/stats +async def get_storage_stats() -> Dict[str, Any] # использование места, статистика + +# POST /api/admin/files/cleanup +async def cleanup_orphaned_files() -> Dict[str, Any] # очистка сирых файлов +``` + +--- + +## 5. Синхронизация файловой системы и БД + +### 5.1 Проблема синхронизации + +**Вызов:** Файлы добавленные вручную в `files/` должны быть видны через API без перезапуска системы. + +**Решения:** +1. **inotify** (Linux) — мониторинг файловой системы в реальном времени +2. **Periodic scan** — сканирование каждые N минут +3. **On-demand scan** — сканирование при API запросе +4. **Гибридный подход** (рекомендуется) + +### 5.2 Гибридная реализация + +```python +class FileSystemSyncService: + def __init__(self, workspace_path: str, db: AsyncSession): + self.workspace_path = workspace_path + self.db = db + self.observer = None # inotify observer + + async def start_monitoring(self): + """Запуск inotify мониторинга для активных проектов.""" + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler + + class ProjectFileHandler(FileSystemEventHandler): + def __init__(self, sync_service): + self.sync_service = sync_service + + def on_created(self, event): + if not event.is_directory: + asyncio.create_task( + self.sync_service.handle_file_created(event.src_path) + ) + + def on_deleted(self, event): + if not event.is_directory: + asyncio.create_task( + self.sync_service.handle_file_deleted(event.src_path) + ) + + def on_modified(self, event): + if not event.is_directory: + asyncio.create_task( + self.sync_service.handle_file_modified(event.src_path) + ) + + self.observer = Observer() + handler = ProjectFileHandler(self) + + # Мониторим папки files/ всех активных проектов + projects = await self.get_active_projects() + for project in projects: + files_path = f"{self.workspace_path}/projects/{project.slug}/files" + if os.path.exists(files_path): + self.observer.schedule(handler, files_path, recursive=True) + + self.observer.start() + + async def handle_file_created(self, file_path: str): + """Обработка создания файла.""" + try: + project_slug, relative_path = self.extract_project_info(file_path) + if not project_slug: + return + + # Проверяем, не дублируется ли файл + existing = await self.find_existing_file(project_slug, relative_path) + if existing: + return + + # Создаём запись в БД + file_info = await self.analyze_file(file_path) + project_file = ProjectFile( + project_id=await self.get_project_id(project_slug), + filename=os.path.basename(file_path), + relative_path=relative_path, + mime_type=file_info.mime_type, + size=file_info.size, + checksum=file_info.checksum, + source="manual", + is_indexed=True, + indexed_at=datetime.now() + ) + + self.db.add(project_file) + await self.db.commit() + + # Уведомляем через WebSocket о новом файле + await self.broadcast_file_event("file.created", project_slug, project_file) + + except Exception as e: + logger.error(f"Error handling file creation {file_path}: {e}") + + async def periodic_scan(self, project_slug: str = None): + """Периодическое сканирование (fallback для inotify).""" + if project_slug: + projects = [await self.get_project(project_slug)] + else: + projects = await self.get_active_projects() + + for project in projects: + await self.scan_project_files(project) + + async def scan_project_files(self, project: Project): + """Сканирование файлов проекта.""" + files_path = f"{self.workspace_path}/projects/{project.slug}/files" + if not os.path.exists(files_path): + return + + # Сканируем файловую систему + fs_files = {} + for root, dirs, files in os.walk(files_path): + for file in files: + full_path = os.path.join(root, file) + relative_path = os.path.relpath(full_path, files_path) + + stat = os.stat(full_path) + fs_files[relative_path] = { + 'size': stat.st_size, + 'mtime': stat.st_mtime, + 'full_path': full_path + } + + # Получаем файлы из БД + db_files = await self.get_project_files_dict(project.id) + + # Находим новые файлы + for relative_path, file_info in fs_files.items(): + if relative_path not in db_files: + await self.create_db_file_record(project, relative_path, file_info) + + # Находим удалённые файлы + for relative_path in db_files: + if relative_path not in fs_files: + await self.mark_file_as_deleted(db_files[relative_path]) + + # Находим изменённые файлы + for relative_path in set(fs_files.keys()) & set(db_files.keys()): + db_file = db_files[relative_path] + fs_file = fs_files[relative_path] + + if fs_file['size'] != db_file.size: + await self.update_file_record(db_file, fs_file) +``` + +### 5.3 Стратегии по типам операций + +| Операция | Метод | Когда | +|----------|-------|-------| +| **Добавление файла** | inotify → немедленная индексация | Real-time | +| **Удаление файла** | inotify → пометка как deleted | Real-time | +| **Изменение файла** | inotify → обновление метаданных | Real-time | +| **Fallback scan** | Periodic (каждые 5 минут) | Если inotify недоступен | +| **Full rescan** | On-demand (API или cron) | При несоответствиях | +| **Startup scan** | При запуске сервиса | Синхронизация после рестарта | + +### 5.4 Индексация и метаданные + +```python +class FileIndexer: + """Извлечение метаданных и индексация контента.""" + + async def analyze_file(self, file_path: str) -> FileAnalysis: + """Полный анализ файла.""" + mime_type = self.detect_mime_type(file_path) + size = os.path.getsize(file_path) + checksum = await self.calculate_checksum(file_path) + + # Извлекаем текст для поиска (если возможно) + text_content = None + if self.is_text_extractable(mime_type): + text_content = await self.extract_text(file_path, mime_type) + + # Генерируем thumbnail для изображений + thumbnail_path = None + if self.is_image(mime_type): + thumbnail_path = await self.generate_thumbnail(file_path) + + return FileAnalysis( + mime_type=mime_type, + size=size, + checksum=checksum, + text_content=text_content, + thumbnail_path=thumbnail_path + ) + + async def extract_text(self, file_path: str, mime_type: str) -> str: + """Извлечение текста для полнотекстового поиска.""" + if mime_type == 'application/pdf': + return await self.extract_pdf_text(file_path) + elif mime_type.startswith('text/'): + return await self.read_text_file(file_path) + elif mime_type in ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']: + return await self.extract_doc_text(file_path) + # ... другие форматы + + return None +``` + +--- + +## 6. Интеграция с агентами (MCP Tools) + +### 6.1 File Tools для MCP + +```python +# В picogent/src/tools/files.ts + +export const listProjectFilesTool: ToolDefinition = { + name: 'list_project_files', + title: 'Список файлов проекта', + description: 'Получить список всех файлов в проекте с фильтрацией', + inputSchema: { + type: 'object', + properties: { + project_slug: { type: 'string', description: 'Slug проекта' }, + path: { type: 'string', description: 'Фильтр по пути (например, docs/)' }, + search: { type: 'string', description: 'Поиск в содержимом файлов' }, + tags: { type: 'array', items: { type: 'string' }, description: 'Фильтр по тегам' }, + mime_type: { type: 'string', description: 'Фильтр по типу файла' } + }, + required: ['project_slug'] + }, + async handler(params, context) { + const files = await context.trackerClient.listProjectFiles(params); + return { + content: [{ + type: 'text', + text: `Найдено файлов: ${files.length}\n${files.map(f => `- ${f.relative_path} (${f.size} bytes)`).join('\n')}` + }], + structuredContent: files + }; + } +}; + +export const uploadProjectFileTool: ToolDefinition = { + name: 'upload_project_file', + title: 'Загрузить файл в проект', + description: 'Загрузить файл в папку files/ проекта', + inputSchema: { + type: 'object', + properties: { + project_slug: { type: 'string', description: 'Slug проекта' }, + filename: { type: 'string', description: 'Имя файла' }, + content: { type: 'string', description: 'Содержимое файла (base64 для бинарных)' }, + path: { type: 'string', description: 'Подпапка в files/ (например, docs/)' }, + description: { type: 'string', description: 'Описание файла' }, + tags: { type: 'array', items: { type: 'string' }, description: 'Теги для категоризации' } + }, + required: ['project_slug', 'filename', 'content'] + }, + async handler(params, context) { + const result = await context.trackerClient.uploadProjectFile(params); + return { + content: [{ + type: 'text', + text: `✅ Файл ${result.filename} загружен в ${result.relative_path}` + }], + structuredContent: result + }; + } +}; + +export const downloadProjectFileTool: ToolDefinition = { + name: 'download_project_file', + title: 'Скачать файл из проекта', + description: 'Скачать содержимое файла проекта', + inputSchema: { + type: 'object', + properties: { + project_slug: { type: 'string', description: 'Slug проекта' }, + file_id: { type: 'string', description: 'ID файла' } + }, + required: ['project_slug', 'file_id'] + }, + async handler(params, context) { + const fileData = await context.trackerClient.downloadProjectFile(params.project_slug, params.file_id); + return { + content: [{ + type: 'text', + text: `📄 Файл ${fileData.filename} (${fileData.size} bytes):\n\n${fileData.content}` + }], + structuredContent: fileData + }; + } +}; + +export const uploadAttachmentTool: ToolDefinition = { + name: 'upload_attachment', + title: 'Прикрепить файл к сообщению', + description: 'Загрузить файл как attachment к сообщению или задаче', + inputSchema: { + type: 'object', + properties: { + message_id: { type: 'string', description: 'ID сообщения (для чата)' }, + task_id: { type: 'string', description: 'ID задачи (для комментария)' }, + filename: { type: 'string', description: 'Имя файла' }, + content: { type: 'string', description: 'Содержимое файла (base64)' } + }, + required: ['filename', 'content'] + }, + async handler(params, context) { + // Одно из полей должно быть заполнено + if (!params.message_id && !params.task_id) { + throw new Error('Необходимо указать message_id или task_id'); + } + + const result = await context.trackerClient.uploadAttachment(params); + return { + content: [{ + type: 'text', + text: `📎 Файл ${result.filename} прикреплён` + }], + structuredContent: result + }; + } +}; + +export const scanProjectFilesTool: ToolDefinition = { + name: 'scan_project_files', + title: 'Сканировать файлы проекта', + description: 'Принудительно сканировать директорию проекта и синхронизировать с БД', + inputSchema: { + type: 'object', + properties: { + project_slug: { type: 'string', description: 'Slug проекта' } + }, + required: ['project_slug'] + }, + async handler(params, context) { + const result = await context.trackerClient.scanProjectFiles(params.project_slug); + return { + content: [{ + type: 'text', + text: `🔄 Сканирование завершено:\n- Найдено: ${result.found}\n- Добавлено: ${result.added}\n- Обновлено: ${result.updated}\n- Удалено: ${result.deleted}` + }], + structuredContent: result + }; + } +}; +``` + +### 6.2 Типичные сценарии использования агентами + +**1. Изучение документации проекта:** +```typescript +// Агент получил задачу и изучает контекст +const projectFiles = await tools.list_project_files({ + project_slug: "team-board", + path: "docs/", + search: "API" +}); + +for (const file of projectFiles.filter(f => f.filename.endsWith('.md'))) { + const content = await tools.download_project_file({ + project_slug: "team-board", + file_id: file.id + }); + // Анализируем содержимое... +} +``` + +**2. Создание технической документации:** +```typescript +// Агент создаёт техническую спецификацию по результатам работы +await tools.upload_project_file({ + project_slug: "team-board", + filename: "FILES-ARCHITECTURE.md", + content: btoa(documentContent), // base64 encoding + path: "docs/", + description: "Архитектура файловой системы", + tags: ["documentation", "architecture"] +}); +``` + +**3. Прикрепление файлов к отчётам:** +```typescript +// Агент выполнил задачу и прикрепляет лог или скриншот +await tools.upload_attachment({ + task_id: "550e8400-e29b-41d4-a716-446655440001", + filename: "test-results.log", + content: btoa(testResults) +}); +``` + +--- + +## 7. Лимиты, квоты и безопасность + +### 7.1 Ограничения по размеру и типам + +```python +class FileValidationService: + def __init__(self, config: FileSystemConfig): + self.config = config + + async def validate_upload(self, file: UploadFile, project: Project, member: Member) -> None: + """Валидация загружаемого файла.""" + + # Проверка размера файла + if file.size > self.config.max_file_size_mb * 1024 * 1024: + raise HTTPException( + status_code=413, + detail=f"Файл слишком большой. Максимум: {self.config.max_file_size_mb}MB" + ) + + # Проверка расширения + extension = os.path.splitext(file.filename)[1].lower() + if extension in self.config.blocked_extensions: + raise HTTPException( + status_code=400, + detail=f"Тип файла {extension} запрещён" + ) + + if (self.config.allowed_extensions and + extension not in self.config.allowed_extensions): + raise HTTPException( + status_code=400, + detail=f"Тип файла {extension} не разрешён" + ) + + # Проверка квоты проекта + current_usage = await self.get_project_storage_usage(project.id) + max_size_bytes = self.config.max_total_size_gb * 1024 * 1024 * 1024 + + if current_usage + file.size > max_size_bytes: + raise HTTPException( + status_code=413, + detail=f"Превышена квота проекта ({self.config.max_total_size_gb}GB)" + ) + + # Проверка количества файлов + file_count = await self.get_project_file_count(project.id) + if file_count >= self.config.max_files_per_project: + raise HTTPException( + status_code=413, + detail=f"Превышено максимальное количество файлов ({self.config.max_files_per_project})" + ) + + async def scan_file_security(self, file_path: str) -> SecurityScanResult: + """Базовое сканирование безопасности.""" + result = SecurityScanResult(safe=True, warnings=[]) + + # Проверка на исполняемые файлы + if self.is_executable(file_path): + result.warnings.append("Файл может содержать исполняемый код") + + # Проверка размера (защита от zip-bomb) + if os.path.getsize(file_path) > 500 * 1024 * 1024: # 500MB + result.safe = False + result.warnings.append("Файл слишком большой для обработки") + + # Проверка на подозрительные расширения в архивах + if file_path.endswith(('.zip', '.tar', '.gz')): + suspicious_files = await self.scan_archive_contents(file_path) + if suspicious_files: + result.warnings.extend(suspicious_files) + + return result +``` + +### 7.2 Дефолтные лимиты + +| Ограничение | Значение | Настраиваемо | +|-------------|----------|--------------| +| **Размер файла** | 100MB | ✅ | +| **Файлов на проект** | 1000 | ✅ | +| **Общий размер проекта** | 10GB | ✅ | +| **Разрешённые типы** | Список расширений | ✅ | +| **Запрещённые типы** | .exe, .bat, .sh, .scr | ✅ | + +### 7.3 Безопасность + +**Защита путей:** +```python +def sanitize_file_path(user_path: str) -> str: + """Очистка пути от path traversal атак.""" + # Удаляем .. и другие опасные конструкции + clean_path = os.path.normpath(user_path) + clean_path = clean_path.replace('..', '').replace('~', '') + + # Убираем ведущие слеши + clean_path = clean_path.lstrip('/') + + return clean_path +``` + +**Контроль доступа:** +- Агенты видят только файлы проектов, к которым у них есть доступ +- Attachments доступны только участникам чата/задачи +- Администраторы имеют полный доступ + +--- + +## 8. Превью изображений и обработка + +### 8.1 Генерация превью + +```python +class ThumbnailService: + def __init__(self, workspace_path: str): + self.workspace_path = workspace_path + self.thumbnail_sizes = [(150, 150), (300, 300)] # small, medium + + async def generate_thumbnails(self, file_path: str, file_id: str) -> List[str]: + """Генерация превью для изображений.""" + if not self.is_image_file(file_path): + return [] + + thumbnails = [] + try: + from PIL import Image + + with Image.open(file_path) as image: + # Конвертируем в RGB если нужно + if image.mode in ('RGBA', 'LA', 'P'): + image = image.convert('RGB') + + for size in self.thumbnail_sizes: + thumbnail = image.copy() + thumbnail.thumbnail(size, Image.Resampling.LANCZOS) + + # Путь для превью + thumb_filename = f"{file_id}_{size[0]}x{size[1]}.jpg" + thumb_path = os.path.join( + self.workspace_path, + "shared/thumbnails", + thumb_filename + ) + + # Создаём директорию если не существует + os.makedirs(os.path.dirname(thumb_path), exist_ok=True) + + # Сохраняем превью + thumbnail.save(thumb_path, 'JPEG', quality=85) + thumbnails.append(f"shared/thumbnails/{thumb_filename}") + + except Exception as e: + logger.error(f"Error generating thumbnails for {file_path}: {e}") + + return thumbnails + + def is_image_file(self, file_path: str) -> bool: + """Проверка, является ли файл изображением.""" + mime_type = mimetypes.guess_type(file_path)[0] + return mime_type and mime_type.startswith('image/') +``` + +### 8.2 Обновление модели для превью + +```python +class ProjectFile(Base): + # ... существующие поля ... + + # Превью + thumbnail_paths: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) # пути к превью + has_thumbnails: Mapped[bool] = mapped_column(Boolean, default=False) + + # Извлечённый текст для поиска + extracted_text: Mapped[str | None] = mapped_column(Text) + is_text_extracted: Mapped[bool] = mapped_column(Boolean, default=False) +``` + +--- + +## 9. Ответы на исследовательские вопросы + +### 1. Как организовать структуру директорий? + +**Решение:** Двухуровневая структура: +- `workspace/projects//files/` — проектные файлы (ручное добавление) +- `workspace/projects//attachments/` — файлы из сообщений/задач +- Подпапки по типу контента: docs/, assets/, configs/ +- Метаданные в `.meta/` для каждого проекта + +### 2. Как синхронизировать файлы положенные руками с БД? + +**Решение:** Гибридный подход: +- **inotify** для мониторинга файловой системы в реальном времени +- **Periodic scan** (каждые 5 минут) как fallback +- **On-demand scan** через API endpoint +- **Startup scan** при запуске сервиса + +### 3. Как привязывать attachments к сообщениям/задачам? + +**Решение:** Используем существующую модель `Attachment`: +- Поле `message_id` для привязки к сообщению +- Сообщения могут быть привязаны к чату (`chat_id`) или задаче (`task_id`) +- Файлы хранятся в `attachments/messages//` или `attachments/tasks//` + +### 4. Нужна ли БД таблица для файлов? + +**Решение:** Да, нужно две таблицы: +- `project_files` — для файлов в папке `files/` (новая) +- `attachments` — для attachments в сообщениях (существующая) +- Обе содержат метаданные, пути, размеры, checksums +- БД обеспечивает быстрый поиск, фильтрацию, авторизацию + +### 5. Как агенты будут скачивать/загружать файлы через MCP tools? + +**Решение:** 6 MCP tools для файлов: +- `list_project_files` — список с фильтрацией +- `upload_project_file` — загрузка в files/ +- `download_project_file` — скачивание с декодированием +- `upload_attachment` — прикрепление к сообщению/задаче +- `scan_project_files` — принудительная синхронизация +- Base64 encoding для передачи бинарных данных + +### 6. Лимиты: размер файла, типы, квоты? + +**Решение:** Настраиваемые лимиты через `FileSystemConfig`: +- Размер файла: 100MB (по умолчанию) +- Файлов на проект: 1000 +- Общий размер проекта: 10GB +- Разрешённые/запрещённые расширения +- Валидация и security scanning при загрузке + +### 7. Превью для изображений? + +**Решение:** Автоматическая генерация превью: +- При загрузке изображений создаются thumbnails (150x150, 300x300) +- Хранятся в `shared/thumbnails/` +- Пути сохраняются в `thumbnail_paths` поле +- Доступны через отдельный API endpoint + +--- + +## 10. Примеры использования + +### 10.1 Пользователь добавляет файл вручную + +```bash +# Пользователь кладёт файл +cp ~/documents/api-spec.md /data/team-board-workspace/projects/team-board/files/docs/ + +# Система автоматически (через inotify): +# 1. Обнаруживает новый файл +# 2. Анализирует (MIME, размер, checksum) +# 3. Создаёт запись в project_files +# 4. Извлекает текст для поиска +# 5. Уведомляет через WebSocket + +# Агент получает уведомление и может сразу прочитать файл +await tools.list_project_files({ + project_slug: "team-board", + search: "API" +}); +``` + +### 10.2 Агент загружает результат работы + +```typescript +// Агент создал диаграмму архитектуры +const diagram = await generateArchitectureDiagram(); + +await tools.upload_project_file({ + project_slug: "team-board", + filename: "architecture-diagram.png", + content: btoa(diagram), // base64 + path: "docs/diagrams/", + description: "Диаграмма архитектуры файловой системы", + tags: ["diagram", "architecture"] +}); + +// Прикрепляем к задаче как attachment +await tools.upload_attachment({ + task_id: taskId, + filename: "architecture-diagram.png", + content: btoa(diagram) +}); +``` + +### 10.3 Поиск и анализ файлов проекта + +```typescript +// Агент ищет все документы по API +const apiDocs = await tools.list_project_files({ + project_slug: "team-board", + search: "API endpoint", + path: "docs/" +}); + +// Читает содержимое для анализа +for (const doc of apiDocs) { + const content = await tools.download_project_file({ + project_slug: "team-board", + file_id: doc.id + }); + + // Анализирует API endpoints + const endpoints = parseApiDocumentation(content.content); + // ... +} +``` + +--- + +## Заключение + +Предложенная архитектура файловой системы для Team Board обеспечивает: + +✅ **Простоту использования** — файлы можно добавлять вручную и через API +✅ **Автоматическую синхронизацию** — inotify + periodic scan +✅ **Богатый API** — REST endpoints + MCP tools для агентов +✅ **Безопасность** — валидация, квоты, контроль доступа +✅ **Масштабируемость** — индексация, поиск, превью +✅ **Интеграцию** — единая модель для attachments и project files + +**Ключевые преимущества:** +- Агенты получают полный доступ к файлам проекта через 6 MCP tools +- Пользователи могут добавлять файлы вручную без перезапуска +- Автоматическая индексация и поиск по содержимому +- Превью изображений и метаданные +- Настраиваемые лимиты и безопасность + +Архитектура готова к реализации и может быть развёрнута поэтапно, начиная с базового функционала и постепенно добавляя продвинутые возможности. \ No newline at end of file