first versuion

This commit is contained in:
itqop 2025-12-30 15:35:19 +03:00
commit 9a7c66e0cc
63 changed files with 5936 additions and 0 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(dir:*)"
]
}
}

58
.gitignore vendored Normal file
View File

@ -0,0 +1,58 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg
*.egg-info/
dist/
build/
.eggs/
venv/
.env
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Build outputs
static/
dist/
dist-ssr/
# Database
*.db
*.db-journal
*.sqlite
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
*.log
# Testing
.coverage
htmlcov/
.pytest_cache/
# Misc
.env.local
.env.development.local
.env.test.local
.env.production.local
.venv

230
CLAUDE.md Normal file
View File

@ -0,0 +1,230 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a cloud photo and video storage service with a React frontend (SPA hosted in S3) and a Python FastAPI backend. The project uses S3 for file storage and starts with SQLite for metadata, with a migration path to PostgreSQL.
**Primary Language:** Russian (comments and documentation may be in Russian)
## Tech Stack
### Backend
- **Framework:** FastAPI (ASGI)
- **ORM:** SQLAlchemy 2.x (async) with Alembic migrations
- **Database:** SQLite (MVP) → PostgreSQL (future)
- **S3 SDK:** boto3 or aioboto3
- **Authentication:** JWT (access + refresh tokens)
- **Background Tasks:** Redis + RQ (recommended for thumbnail generation)
- **Testing:** pytest + httpx
- **Linting/Formatting:** ruff + black (or ruff format)
### Frontend
- **Framework:** React
- **UI Library:** Material UI (MUI)
- **Build Tool:** Vite
- **Deployment:** Static files in `static/` folder → S3 hosting
### Infrastructure
- **File Storage:** S3 or S3-compatible (MinIO for development)
- **Queue:** Redis (for background workers)
## Architecture Principles
### Layered Architecture (Clean Architecture)
The backend follows strict separation of concerns:
- **`api/`** - Routes, schemas, dependencies, auth middleware
- **`services/`** - Business logic (upload, library, share management)
- **`repositories/`** - Data access layer (CRUD operations)
- **`infra/`** - S3 client, database session factory, config, background tasks
- **`domain/`** - Domain models and interfaces (as needed)
### Code Quality Standards
- **SOLID principles** are mandatory
- **DRY (Don't Repeat Yourself)**
- **Docstrings required** for all public methods/classes and main services
- **Minimal code comments** - prefer self-documenting code and docstrings
- **Stable API contract** - maintain OpenAPI/Swagger compatibility
### Security Requirements
- Never store passwords in plaintext - use argon2id or bcrypt
- All S3 access must use pre-signed URLs with short TTL
- Validate all input with Pydantic
- Check asset ownership in all endpoints
- CORS must be strictly configured
- CSRF protection for cookie-based auth
## Planned Project Structure
```
repo/
backend/
src/
app/
api/
v1/ # API routes versioned
services/ # Business logic layer
repositories/ # Data access layer
infra/ # Infrastructure (S3, DB, config)
domain/ # Domain models
main.py # Application entry point
alembic/ # Database migrations
tests/
pyproject.toml
Dockerfile
frontend/
src/
public/
vite.config.js
package.json
static/ # Build output (deployed to S3)
docker-compose.yml
```
## Data Model
### Core Entities
**users**: User accounts (id, email, password_hash, created_at, updated_at, is_active)
**assets**: Media files with metadata
- Type: photo | video
- Status: uploading | ready | failed | deleted
- Includes: original_filename, content_type, size_bytes, sha256, captured_at
- S3 keys: storage_key_original, storage_key_thumb
- Soft delete support via deleted_at
**shares**: Public/private sharing links
- Links to either a single asset or album
- Supports expiration (expires_at) and revocation (revoked_at)
- Optional password protection
**albums** (v1): Logical grouping of assets
**tags** (v1): Tagging system for assets
## S3 Storage Structure
- **Bucket:** Private, access only via pre-signed URLs
- **Original files:** `u/{user_id}/o/{yyyy}/{mm}/{asset_id}{ext}`
- **Thumbnails:** `u/{user_id}/t/{yyyy}/{mm}/{asset_id}.jpg`
- **Video posters (v1):** `u/{user_id}/p/{yyyy}/{mm}/{asset_id}.jpg`
## Upload Flow
1. Frontend requests `POST /api/v1/uploads/create` with file metadata
2. Backend returns pre-signed URL or multipart upload credentials
3. Frontend uploads file directly to S3
4. Frontend calls `POST /api/v1/uploads/{asset_id}/finalize`
5. Backend saves metadata and enqueues thumbnail generation task
## API Structure
Base path: `/api/v1`
### Authentication
- `POST /api/v1/auth/register`
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/logout`
- `GET /api/v1/auth/me`
### Assets (Library)
- `GET /api/v1/assets` - List with cursor-based pagination
- `GET /api/v1/assets/{asset_id}`
- `DELETE /api/v1/assets/{asset_id}` - Soft delete
- `POST /api/v1/assets/{asset_id}/restore`
- `DELETE /api/v1/assets/{asset_id}/purge` - Hard delete from trash
### Upload
- `POST /api/v1/uploads/create`
- `POST /api/v1/uploads/{asset_id}/finalize`
### Access URLs
- `GET /api/v1/assets/{asset_id}/download-url?kind=original|thumb`
- `GET /api/v1/assets/{asset_id}/stream-url` - For video
### Shares
- `POST /api/v1/shares` - Create share link
- `GET /api/v1/shares/{token}`
- `GET /api/v1/shares/{token}/download-url?asset_id=&kind=`
- `POST /api/v1/shares/{token}/revoke`
## Environment Variables
Key backend environment variables (see [tech_spec_cloud_media_storage.md](tech_spec_cloud_media_storage.md) section 13 for full list):
- `APP_ENV=dev|prod`
- `DATABASE_URL=sqlite+aiosqlite:///./app.db` (or PostgreSQL connection string)
- `S3_ENDPOINT_URL` - For MinIO or custom S3-compatible storage
- `S3_REGION`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`
- `MEDIA_BUCKET` - S3 bucket name
- `SIGNED_URL_TTL_SECONDS=600`
- `JWT_SECRET`, `JWT_ACCESS_TTL_SECONDS`, `JWT_REFRESH_TTL_SECONDS`
- `MAX_UPLOAD_SIZE_BYTES` - File size limit
- `CORS_ORIGINS` - Allowed frontend origins
## Database Migrations
- All schema changes MUST go through Alembic migrations
- No raw SQL in application code (except migrations)
- Schema must be compatible with both SQLite (MVP) and PostgreSQL
- UUID stored as TEXT in SQLite, native UUID in PostgreSQL
## Development Workflow (When Implemented)
### Backend Development
Expected commands once backend is set up:
- Start server: `uvicorn app.main:app --reload`
- Run tests: `pytest`
- Create migration: `alembic revision --autogenerate -m "description"`
- Apply migrations: `alembic upgrade head`
- Format code: `ruff format .` or `black .`
- Lint: `ruff check .`
### Frontend Development
Expected commands once frontend is set up:
- Install dependencies: `npm install`
- Dev server: `npm run dev`
- Build for production: `npm run build` (outputs to `static/`)
- Lint: `npm run lint`
### Docker Compose (Development)
Recommended setup: `docker-compose up` to start:
- Backend service
- MinIO (S3-compatible storage)
- Redis (for background tasks)
- PostgreSQL (when migrating from SQLite)
## MVP Acceptance Criteria
- User registration and authentication working
- Upload photo and video files (including batch upload of 100+ files)
- Library displays thumbnails for photos
- Photo/video viewer works in browser
- Soft delete to trash with restore capability
- Public share links work without authentication
- SQLite database with migration path to PostgreSQL ready
## Implementation Phases
1. **Foundation:** FastAPI skeleton, DB config, migrations, auth
2. **Assets:** CRUD for assets, library listing with pagination, trash management
3. **Frontend MVP:** Login, library grid, upload dialog, viewer, trash UI
4. **Thumbnails:** Background generation and display
5. **Shares:** Create and access share links, shared view UI
## Important Notes
- This project follows **Clean Architecture** - respect layer boundaries
- All file access goes through **pre-signed S3 URLs**, never direct access
- Use **cursor-based pagination** for listing endpoints
- **Thumbnails reduce bandwidth** - originals loaded only on demand
- For large files, use **S3 multipart upload**
- Background tasks via **Redis + RQ** for thumbnail/poster generation
- Support both **inline** (MVP acceptable) and **background** thumbnail generation
## Reference Documentation
For detailed technical requirements, see [tech_spec_cloud_media_storage.md](tech_spec_cloud_media_storage.md).

230
README.md Normal file
View File

@ -0,0 +1,230 @@
# ITCloud - Облачное хранилище фото и видео
Современное облачное хранилище для фото и видео с удобным веб-интерфейсом, построенное на React + Material UI и Python FastAPI.
## Особенности
- 📸 **Загрузка фото и видео** - поддержка drag & drop, пакетная загрузка
- 🖼️ **Удобная галерея** - сетка с превью, быстрый просмотр
- 🎬 **Видео плеер** - встроенный плеер для просмотра видео
- 🗑️ **Корзина** - мягкое удаление с возможностью восстановления
- 🔗 **Шаринг** - публичные ссылки с возможностью установить срок действия и пароль
- 📱 **Responsive дизайн** - отлично работает на мобильных устройствах и десктопе
- 🔐 **Безопасность** - JWT аутентификация, pre-signed URLs для S3
## Технологии
### Backend
- **FastAPI** - современный асинхронный веб-фреймворк
- **SQLAlchemy 2.0** - ORM с асинхронной поддержкой
- **SQLite / PostgreSQL** - база данных (легкая миграция)
- **S3 / MinIO** - хранилище объектов
- **Alembic** - миграции базы данных
- **JWT** - аутентификация
### Frontend
- **React 18** - современная библиотека для UI
- **TypeScript** - типизированный JavaScript
- **Material UI (MUI)** - готовые компоненты с Material Design
- **Vite** - быстрая сборка
- **React Router** - маршрутизация
- **Axios** - HTTP клиент
## Быстрый старт
### Предварительные требования
- Docker и Docker Compose
- Node.js 20+ (для разработки фронтенда без Docker)
- Python 3.11+ (для разработки backend без Docker)
### Запуск с Docker Compose (рекомендуется)
1. Клонируйте репозиторий:
```bash
git clone <repository-url>
cd itcloud
```
2. Запустите все сервисы:
```bash
docker-compose up
```
Это запустит:
- Backend API на http://localhost:8000
- Frontend на http://localhost:5173
- MinIO (S3) на http://localhost:9000 (консоль: http://localhost:9001)
- Redis на localhost:6379
3. Откройте браузер и перейдите на http://localhost:5173
### Разработка без Docker
#### Backend
1. Установите зависимости:
```bash
cd backend
pip install poetry
poetry install
```
2. Создайте файл `.env`:
```bash
cp .env.example .env
# Отредактируйте .env с вашими настройками
```
3. Примените миграции:
```bash
poetry run alembic upgrade head
```
4. Запустите сервер:
```bash
poetry run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
#### Frontend
1. Установите зависимости:
```bash
cd frontend
npm install
```
2. Создайте файл `.env`:
```bash
echo "VITE_API_URL=http://localhost:8000" > .env.local
```
3. Запустите dev сервер:
```bash
npm run dev
```
## Структура проекта
```
itcloud/
├── backend/ # Python FastAPI backend
│ ├── src/app/
│ │ ├── api/ # API routes
│ │ │ └── v1/ # API v1 endpoints
│ │ ├── services/ # Business logic
│ │ ├── repositories/ # Data access layer
│ │ ├── infra/ # Infrastructure (S3, DB, config)
│ │ └── domain/ # Domain models
│ ├── alembic/ # Database migrations
│ ├── tests/ # Tests
│ └── pyproject.toml # Python dependencies
├── frontend/ # React frontend
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── pages/ # Page components
│ │ ├── services/ # API client
│ │ ├── hooks/ # Custom hooks
│ │ ├── types/ # TypeScript types
│ │ └── theme/ # MUI theme
│ └── package.json # Node dependencies
├── docker-compose.yml # Docker Compose configuration
└── CLAUDE.md # Developer documentation
```
## API Документация
После запуска backend, документация доступна по адресу:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## Основные эндпоинты
### Аутентификация
- `POST /api/v1/auth/register` - Регистрация
- `POST /api/v1/auth/login` - Вход
- `GET /api/v1/auth/me` - Получить текущего пользователя
### Файлы
- `GET /api/v1/assets` - Список файлов
- `GET /api/v1/assets/{id}` - Информация о файле
- `DELETE /api/v1/assets/{id}` - Удалить (в корзину)
- `POST /api/v1/assets/{id}/restore` - Восстановить из корзины
- `DELETE /api/v1/assets/{id}/purge` - Удалить навсегда
### Загрузка
- `POST /api/v1/uploads/create` - Создать загрузку
- `POST /api/v1/uploads/{id}/finalize` - Завершить загрузку
### Шаринг
- `POST /api/v1/shares` - Создать публичную ссылку
- `GET /api/v1/shares/{token}` - Получить информацию о ссылке
- `GET /api/v1/shares/{token}/download-url` - Получить URL для скачивания
## Переменные окружения
### Backend
```env
APP_ENV=dev
DATABASE_URL=sqlite+aiosqlite:///./app.db
S3_ENDPOINT_URL=http://localhost:9000
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
MEDIA_BUCKET=itcloud-media
JWT_SECRET=your-secret-key
CORS_ORIGINS=http://localhost:5173
```
### Frontend
```env
VITE_API_URL=http://localhost:8000
```
## Миграция на PostgreSQL
1. Обновите `DATABASE_URL` в `.env`:
```env
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/itcloud
```
2. Примените миграции:
```bash
poetry run alembic upgrade head
```
## Деплой
### Production Build Frontend
```bash
cd frontend
npm run build
```
Файлы будут собраны в `static/` и готовы для хостинга в S3 или через nginx.
### Backend в Production
1. Используйте PostgreSQL вместо SQLite
2. Настройте CORS для вашего домена
3. Используйте сильный JWT_SECRET
4. Настройте SSL/TLS
5. Используйте gunicorn/uvicorn с несколькими воркерами
## Следующие шаги (TODO)
- [ ] Redis + RQ для фоновых задач
- [ ] Генерация превью для фото
- [ ] Генерация постеров для видео
- [ ] Извлечение EXIF данных
- [ ] Альбомы
- [ ] Теги
- [ ] Поиск по метаданным
- [ ] Квоты пользователей
- [ ] Тесты
## Лицензия
MIT
## Поддержка
Для вопросов и предложений создавайте issue в репозитории.

20
backend/.dockerignore Normal file
View File

@ -0,0 +1,20 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.env
.venv
venv
*.db
*.db-journal
.pytest_cache
.coverage
htmlcov
.mypy_cache
.ruff_cache

35
backend/.env.example Normal file
View File

@ -0,0 +1,35 @@
# Application
APP_ENV=dev
APP_HOST=0.0.0.0
APP_PORT=8000
# Database
DATABASE_URL=sqlite+aiosqlite:///./app.db
# For PostgreSQL: postgresql+asyncpg://user:password@localhost:5432/itcloud
# S3 Storage
S3_ENDPOINT_URL=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
MEDIA_BUCKET=itcloud-media
# Security
JWT_SECRET=your-secret-key-change-this-in-production
JWT_ALGORITHM=HS256
JWT_ACCESS_TTL_SECONDS=900
JWT_REFRESH_TTL_SECONDS=1209600
# Upload limits
MAX_UPLOAD_SIZE_BYTES=21474836480
SIGNED_URL_TTL_SECONDS=600
# CORS
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# Redis
REDIS_URL=redis://localhost:6379/0
# Thumbnails
THUMBNAIL_MAX_SIZE=1024
THUMBNAIL_QUALITY=85

50
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,50 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
.env
.venv
venv/
ENV/
# Database
*.db
*.db-journal
*.sqlite
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
# Type checking
.mypy_cache/
.dmypy.json
# Linting
.ruff_cache/
# Logs
*.log
# OS
.DS_Store
Thumbs.db

28
backend/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install poetry
RUN pip install poetry==1.7.1
# Copy dependency files
COPY pyproject.toml ./
# Install dependencies
RUN poetry config virtualenvs.create false \
&& poetry install --no-dev --no-interaction --no-ansi
# Copy application code
COPY src/ ./src/
# Create directory for SQLite database
RUN mkdir -p /app/data
ENV PYTHONPATH=/app/src
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

112
backend/alembic.ini Normal file
View File

@ -0,0 +1,112 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
prepend_sys_path = src
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite+aiosqlite:///./app.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

77
backend/alembic/env.py Normal file
View File

@ -0,0 +1,77 @@
"""Alembic environment configuration."""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import your models here
from app.domain.models import Base
from app.infra.config import get_settings
# this is the Alembic Config object
config = context.config
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Get database URL from settings
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
# Add your model's MetaData object here for 'autogenerate' support
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Run migrations with connection."""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in async mode."""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = settings.database_url
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

52
backend/pyproject.toml Normal file
View File

@ -0,0 +1,52 @@
[tool.poetry]
name = "itcloud-backend"
version = "0.1.0"
description = "Cloud photo and video storage backend"
authors = ["ITCloud Team"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.109.0"
uvicorn = {extras = ["standard"], version = "^0.27.0"}
sqlalchemy = {extras = ["asyncio"], version = "^2.0.25"}
alembic = "^1.13.1"
pydantic = "^2.5.3"
pydantic-settings = "^2.1.0"
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
passlib = {extras = ["bcrypt"], version = "^1.7.4"}
python-multipart = "^0.0.6"
aiosqlite = "^0.19.0"
asyncpg = "^0.29.0"
boto3 = "^1.34.34"
aioboto3 = "^12.3.0"
redis = "^5.0.1"
rq = "^1.16.1"
pillow = "^10.2.0"
python-magic = "^0.4.27"
loguru = "^0.7.2"
httpx = "^0.26.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4.4"
pytest-asyncio = "^0.23.3"
pytest-cov = "^4.1.0"
ruff = "^0.1.14"
black = "^24.1.1"
mypy = "^1.8.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.black]
line-length = 100
target-version = ["py311"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View File

@ -0,0 +1 @@
"""ITCloud backend application."""

View File

@ -0,0 +1 @@
"""API layer."""

View File

@ -0,0 +1,66 @@
"""API dependencies for dependency injection."""
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import User
from app.infra.database import get_db
from app.infra.s3_client import S3Client, get_s3_client
from app.infra.security import decode_token
from app.repositories.user_repository import UserRepository
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
session: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""
Get current authenticated user from JWT token.
Args:
credentials: HTTP authorization credentials
session: Database session
Returns:
Current user
Raises:
HTTPException: If token is invalid or user not found
"""
token = credentials.credentials
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
user_repo = UserRepository(session)
user = await user_repo.get_by_id(user_id)
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
return user
# Type aliases for dependency injection
CurrentUser = Annotated[User, Depends(get_current_user)]
DatabaseSession = Annotated[AsyncSession, Depends(get_db)]
S3ClientDep = Annotated[S3Client, Depends(get_s3_client)]

View File

@ -0,0 +1,144 @@
"""Pydantic schemas for API requests and responses."""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
from app.domain.models import AssetStatus, AssetType
# Auth schemas
class UserRegister(BaseModel):
"""User registration request."""
email: EmailStr
password: str = Field(min_length=8, max_length=100)
class UserLogin(BaseModel):
"""User login request."""
email: EmailStr
password: str
class Token(BaseModel):
"""JWT token response."""
access_token: str
refresh_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
"""User information response."""
id: str
email: str
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
# Asset schemas
class AssetResponse(BaseModel):
"""Asset information response."""
id: str
user_id: str
type: AssetType
status: AssetStatus
original_filename: str
content_type: str
size_bytes: int
sha256: Optional[str] = None
captured_at: Optional[datetime] = None
width: Optional[int] = None
height: Optional[int] = None
duration_sec: Optional[float] = None
storage_key_original: str
storage_key_thumb: Optional[str] = None
created_at: datetime
deleted_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class AssetListResponse(BaseModel):
"""Paginated list of assets."""
items: list[AssetResponse]
next_cursor: Optional[str] = None
has_more: bool
class CreateUploadRequest(BaseModel):
"""Request to create an upload."""
original_filename: str = Field(max_length=512)
content_type: str = Field(max_length=100)
size_bytes: int = Field(gt=0)
class CreateUploadResponse(BaseModel):
"""Response with upload credentials."""
asset_id: str
upload_url: str
upload_method: str = "presigned_post"
fields: Optional[dict] = None
class FinalizeUploadRequest(BaseModel):
"""Request to finalize an upload."""
etag: Optional[str] = None
sha256: Optional[str] = Field(None, max_length=64)
# Download URLs
class DownloadUrlResponse(BaseModel):
"""Pre-signed download URL."""
url: str
expires_in: int
# Share schemas
class CreateShareRequest(BaseModel):
"""Request to create a share link."""
asset_id: Optional[str] = None
album_id: Optional[str] = None
expires_in_seconds: Optional[int] = Field(None, gt=0)
password: Optional[str] = None
class ShareResponse(BaseModel):
"""Share link information."""
id: str
owner_user_id: str
asset_id: Optional[str] = None
album_id: Optional[str] = None
token: str
expires_at: Optional[datetime] = None
created_at: datetime
revoked_at: Optional[datetime] = None
model_config = {"from_attributes": True}
class ShareAccessRequest(BaseModel):
"""Request to access a shared resource."""
password: Optional[str] = None
# Error response
class ErrorResponse(BaseModel):
"""Standard error response."""
error: dict[str, str | dict]

View File

@ -0,0 +1 @@
"""API v1 routes."""

View File

@ -0,0 +1,174 @@
"""Assets API routes."""
from typing import Optional
from fastapi import APIRouter, Query, status
from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep
from app.api.schemas import AssetListResponse, AssetResponse, DownloadUrlResponse
from app.domain.models import AssetType
from app.infra.config import get_settings
from app.services.asset_service import AssetService
router = APIRouter(prefix="/assets", tags=["assets"])
settings = get_settings()
@router.get("", response_model=AssetListResponse)
async def list_assets(
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
cursor: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
type: Optional[AssetType] = Query(None),
):
"""
List user's assets with pagination.
Args:
current_user: Current authenticated user
session: Database session
s3_client: S3 client
cursor: Pagination cursor
limit: Maximum number of results
type: Filter by asset type
Returns:
Paginated list of assets
"""
asset_service = AssetService(session, s3_client)
assets, next_cursor, has_more = await asset_service.list_assets(
user_id=current_user.id,
limit=limit,
cursor=cursor,
asset_type=type,
)
return AssetListResponse(
items=assets,
next_cursor=next_cursor,
has_more=has_more,
)
@router.get("/{asset_id}", response_model=AssetResponse)
async def get_asset(
asset_id: str,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
):
"""
Get asset by ID.
Args:
asset_id: Asset ID
current_user: Current authenticated user
session: Database session
s3_client: S3 client
Returns:
Asset information
"""
asset_service = AssetService(session, s3_client)
asset = await asset_service.get_asset(user_id=current_user.id, asset_id=asset_id)
return asset
@router.get("/{asset_id}/download-url", response_model=DownloadUrlResponse)
async def get_download_url(
asset_id: str,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
kind: str = Query("original", regex="^(original|thumb)$"),
):
"""
Get pre-signed download URL for an asset.
Args:
asset_id: Asset ID
current_user: Current authenticated user
session: Database session
s3_client: S3 client
kind: 'original' or 'thumb'
Returns:
Pre-signed download URL
"""
asset_service = AssetService(session, s3_client)
url = await asset_service.get_download_url(
user_id=current_user.id,
asset_id=asset_id,
kind=kind,
)
return DownloadUrlResponse(url=url, expires_in=settings.signed_url_ttl_seconds)
@router.delete("/{asset_id}", response_model=AssetResponse)
async def delete_asset(
asset_id: str,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
):
"""
Soft delete an asset (move to trash).
Args:
asset_id: Asset ID
current_user: Current authenticated user
session: Database session
s3_client: S3 client
Returns:
Updated asset
"""
asset_service = AssetService(session, s3_client)
asset = await asset_service.delete_asset(user_id=current_user.id, asset_id=asset_id)
return asset
@router.post("/{asset_id}/restore", response_model=AssetResponse)
async def restore_asset(
asset_id: str,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
):
"""
Restore a soft-deleted asset.
Args:
asset_id: Asset ID
current_user: Current authenticated user
session: Database session
s3_client: S3 client
Returns:
Updated asset
"""
asset_service = AssetService(session, s3_client)
asset = await asset_service.restore_asset(user_id=current_user.id, asset_id=asset_id)
return asset
@router.delete("/{asset_id}/purge", status_code=status.HTTP_204_NO_CONTENT)
async def purge_asset(
asset_id: str,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
):
"""
Permanently delete an asset.
Args:
asset_id: Asset ID
current_user: Current authenticated user
session: Database session
s3_client: S3 client
"""
asset_service = AssetService(session, s3_client)
await asset_service.purge_asset(user_id=current_user.id, asset_id=asset_id)

View File

@ -0,0 +1,59 @@
"""Authentication API routes."""
from fastapi import APIRouter, status
from app.api.dependencies import CurrentUser, DatabaseSession
from app.api.schemas import Token, UserLogin, UserRegister, UserResponse
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(data: UserRegister, session: DatabaseSession):
"""
Register a new user.
Args:
data: Registration data
session: Database session
Returns:
Created user information
"""
auth_service = AuthService(session)
user = await auth_service.register(email=data.email, password=data.password)
return user
@router.post("/login", response_model=Token)
async def login(data: UserLogin, session: DatabaseSession):
"""
Authenticate user and get access tokens.
Args:
data: Login credentials
session: Database session
Returns:
Access and refresh tokens
"""
auth_service = AuthService(session)
access_token, refresh_token = await auth_service.login(
email=data.email, password=data.password
)
return Token(access_token=access_token, refresh_token=refresh_token)
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: CurrentUser):
"""
Get current user information.
Args:
current_user: Current authenticated user
Returns:
User information
"""
return current_user

View File

@ -0,0 +1,132 @@
"""Share API routes."""
from typing import Optional
from fastapi import APIRouter, Query, status
from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep
from app.api.schemas import (
CreateShareRequest,
DownloadUrlResponse,
ShareAccessRequest,
ShareResponse,
)
from app.infra.config import get_settings
from app.services.share_service import ShareService
router = APIRouter(prefix="/shares", tags=["shares"])
settings = get_settings()
@router.post("", response_model=ShareResponse, status_code=status.HTTP_201_CREATED)
async def create_share(
data: CreateShareRequest,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
):
"""
Create a share link.
Args:
data: Share creation data
current_user: Current authenticated user
session: Database session
s3_client: S3 client
Returns:
Created share information
"""
share_service = ShareService(session, s3_client)
share = await share_service.create_share(
user_id=current_user.id,
asset_id=data.asset_id,
album_id=data.album_id,
expires_in_seconds=data.expires_in_seconds,
password=data.password,
)
return share
@router.get("/{token}", response_model=ShareResponse)
async def get_share(
token: str,
session: DatabaseSession,
s3_client: S3ClientDep,
password: Optional[str] = Query(None),
):
"""
Get share information by token.
Args:
token: Share token
session: Database session
s3_client: S3 client
password: Optional password for protected shares
Returns:
Share information
"""
share_service = ShareService(session, s3_client)
share = await share_service.get_share(token=token, password=password)
return share
@router.get("/{token}/download-url", response_model=DownloadUrlResponse)
async def get_share_download_url(
token: str,
session: DatabaseSession,
s3_client: S3ClientDep,
asset_id: str = Query(...),
kind: str = Query("original", regex="^(original|thumb)$"),
password: Optional[str] = Query(None),
):
"""
Get download URL for a shared asset.
Args:
token: Share token
session: Database session
s3_client: S3 client
asset_id: Asset ID
kind: 'original' or 'thumb'
password: Optional password
Returns:
Pre-signed download URL
"""
share_service = ShareService(session, s3_client)
url = await share_service.get_share_download_url(
token=token,
asset_id=asset_id,
kind=kind,
password=password,
)
return DownloadUrlResponse(url=url, expires_in=settings.signed_url_ttl_seconds)
@router.post("/{token}/revoke", response_model=ShareResponse)
async def revoke_share(
token: str,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
):
"""
Revoke a share link.
Args:
token: Share token
current_user: Current authenticated user
session: Database session
s3_client: S3 client
Returns:
Updated share
"""
share_service = ShareService(session, s3_client)
# First get the share to find its ID
share = await share_service.get_share(token=token)
# Then revoke it
share = await share_service.revoke_share(user_id=current_user.id, share_id=share.id)
return share

View File

@ -0,0 +1,80 @@
"""Upload API routes."""
from fastapi import APIRouter, status
from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep
from app.api.schemas import (
AssetResponse,
CreateUploadRequest,
CreateUploadResponse,
FinalizeUploadRequest,
)
from app.services.asset_service import AssetService
router = APIRouter(prefix="/uploads", tags=["uploads"])
@router.post("/create", response_model=CreateUploadResponse, status_code=status.HTTP_201_CREATED)
async def create_upload(
data: CreateUploadRequest,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
):
"""
Create an upload and get pre-signed URL.
Args:
data: Upload creation data
current_user: Current authenticated user
session: Database session
s3_client: S3 client
Returns:
Upload credentials
"""
asset_service = AssetService(session, s3_client)
asset, presigned_post = await asset_service.create_upload(
user_id=current_user.id,
original_filename=data.original_filename,
content_type=data.content_type,
size_bytes=data.size_bytes,
)
return CreateUploadResponse(
asset_id=asset.id,
upload_url=presigned_post["url"],
upload_method="presigned_post",
fields=presigned_post["fields"],
)
@router.post("/{asset_id}/finalize", response_model=AssetResponse)
async def finalize_upload(
asset_id: str,
data: FinalizeUploadRequest,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
):
"""
Finalize upload after file is uploaded to S3.
Args:
asset_id: Asset ID
data: Finalization data
current_user: Current authenticated user
session: Database session
s3_client: S3 client
Returns:
Updated asset information
"""
asset_service = AssetService(session, s3_client)
asset = await asset_service.finalize_upload(
user_id=current_user.id,
asset_id=asset_id,
etag=data.etag,
sha256=data.sha256,
)
return asset

View File

@ -0,0 +1 @@
"""Domain layer."""

View File

@ -0,0 +1,106 @@
"""Domain models for the application."""
import enum
from datetime import datetime
from typing import Optional
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, Enum, Float, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.infra.database import Base
def generate_uuid() -> str:
"""Generate UUID as string for SQLite compatibility."""
return str(uuid4())
class AssetType(str, enum.Enum):
"""Type of media asset."""
PHOTO = "photo"
VIDEO = "video"
class AssetStatus(str, enum.Enum):
"""Status of media asset."""
UPLOADING = "uploading"
READY = "ready"
FAILED = "failed"
DELETED = "deleted"
class User(Base):
"""User account."""
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
class Asset(Base):
"""Media asset (photo or video)."""
__tablename__ = "assets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
type: Mapped[AssetType] = mapped_column(Enum(AssetType), nullable=False)
status: Mapped[AssetStatus] = mapped_column(
Enum(AssetStatus), default=AssetStatus.UPLOADING, nullable=False, index=True
)
original_filename: Mapped[str] = mapped_column(String(512), nullable=False)
content_type: Mapped[str] = mapped_column(String(100), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
sha256: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
# Metadata
captured_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
width: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
height: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
duration_sec: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
# Storage
storage_key_original: Mapped[str] = mapped_column(String(512), nullable=False)
storage_key_thumb: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
class Share(Base):
"""Public share link for assets or albums."""
__tablename__ = "shares"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
owner_user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
asset_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True)
album_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True, index=True)
token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
revoked_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)

View File

@ -0,0 +1 @@
"""Infrastructure layer."""

View File

@ -0,0 +1,64 @@
"""Application configuration management."""
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Application
app_env: Literal["dev", "prod"] = "dev"
app_host: str = "0.0.0.0"
app_port: int = 8000
# Database
database_url: str = "sqlite+aiosqlite:///./app.db"
# S3 Storage
s3_endpoint_url: str | None = None
s3_region: str = "us-east-1"
s3_access_key_id: str
s3_secret_access_key: str
media_bucket: str = "itcloud-media"
# Security
jwt_secret: str
jwt_algorithm: str = "HS256"
jwt_access_ttl_seconds: int = 900
jwt_refresh_ttl_seconds: int = 1209600
# Upload limits
max_upload_size_bytes: int = 21474836480 # 20GB
signed_url_ttl_seconds: int = 600
# CORS
cors_origins: str = "http://localhost:5173"
@property
def cors_origins_list(self) -> list[str]:
"""Parse CORS origins as a list."""
return [origin.strip() for origin in self.cors_origins.split(",")]
# Redis
redis_url: str = "redis://localhost:6379/0"
# Thumbnails
thumbnail_max_size: int = 1024
thumbnail_quality: int = 85
@lru_cache
def get_settings() -> Settings:
"""Get cached application settings."""
return Settings()

View File

@ -0,0 +1,57 @@
"""Database session management."""
from collections.abc import AsyncGenerator
from typing import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.infra.config import get_settings
settings = get_settings()
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=settings.app_env == "dev",
future=True,
)
# Create session factory
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
class Base(DeclarativeBase):
"""Base class for all database models."""
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency that provides database session.
Yields:
AsyncSession: Database session
"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""Initialize database tables."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@ -0,0 +1,148 @@
"""S3 client for file storage operations."""
from datetime import datetime
from typing import Optional
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from app.infra.config import get_settings
settings = get_settings()
class S3Client:
"""Client for S3 storage operations."""
def __init__(self):
"""Initialize S3 client."""
self.client = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
region_name=settings.s3_region,
aws_access_key_id=settings.s3_access_key_id,
aws_secret_access_key=settings.s3_secret_access_key,
config=Config(signature_version="s3v4"),
)
self.bucket = settings.media_bucket
def generate_storage_key(
self, user_id: str, asset_id: str, prefix: str, extension: str
) -> str:
"""
Generate S3 storage key for an asset.
Args:
user_id: User ID
asset_id: Asset ID
prefix: Key prefix (o for original, t for thumbnail, p for poster)
extension: File extension
Returns:
Storage key
"""
now = datetime.utcnow()
year = now.strftime("%Y")
month = now.strftime("%m")
return f"u/{user_id}/{prefix}/{year}/{month}/{asset_id}{extension}"
def generate_presigned_post(
self, storage_key: str, content_type: str, max_size: int
) -> dict:
"""
Generate pre-signed POST data for direct upload.
Args:
storage_key: S3 object key
content_type: File content type
max_size: Maximum file size in bytes
Returns:
Dictionary with 'url' and 'fields' for POST request
"""
conditions = [
{"Content-Type": content_type},
["content-length-range", 1, max_size],
]
presigned_post = self.client.generate_presigned_post(
Bucket=self.bucket,
Key=storage_key,
Fields={"Content-Type": content_type},
Conditions=conditions,
ExpiresIn=settings.signed_url_ttl_seconds,
)
return presigned_post
def generate_presigned_url(
self, storage_key: str, expires_in: Optional[int] = None
) -> str:
"""
Generate pre-signed URL for download.
Args:
storage_key: S3 object key
expires_in: Expiration time in seconds
Returns:
Pre-signed URL
"""
if expires_in is None:
expires_in = settings.signed_url_ttl_seconds
url = self.client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": storage_key},
ExpiresIn=expires_in,
)
return url
def upload_file(self, file_path: str, storage_key: str, content_type: str) -> None:
"""
Upload a file to S3.
Args:
file_path: Local file path
storage_key: S3 object key
content_type: File content type
"""
self.client.upload_file(
file_path,
self.bucket,
storage_key,
ExtraArgs={"ContentType": content_type},
)
def delete_object(self, storage_key: str) -> None:
"""
Delete an object from S3.
Args:
storage_key: S3 object key
"""
try:
self.client.delete_object(Bucket=self.bucket, Key=storage_key)
except ClientError:
pass
def object_exists(self, storage_key: str) -> bool:
"""
Check if an object exists in S3.
Args:
storage_key: S3 object key
Returns:
True if object exists, False otherwise
"""
try:
self.client.head_object(Bucket=self.bucket, Key=storage_key)
return True
except ClientError:
return False
def get_s3_client() -> S3Client:
"""Get S3 client instance."""
return S3Client()

View File

@ -0,0 +1,97 @@
"""Security utilities for authentication and authorization."""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.infra.config import get_settings
settings = get_settings()
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Hashed password
"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against its hash.
Args:
plain_password: Plain text password
hashed_password: Hashed password to verify against
Returns:
True if password matches, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
Args:
data: Data to encode in the token
expires_delta: Optional expiration time delta
Returns:
Encoded JWT token
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(seconds=settings.jwt_access_ttl_seconds)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""
Create a JWT refresh token.
Args:
data: Data to encode in the token
Returns:
Encoded JWT token
"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(seconds=settings.jwt_refresh_ttl_seconds)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
"""
Decode and verify a JWT token.
Args:
token: JWT token to decode
Returns:
Decoded token payload or None if invalid
"""
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
return payload
except JWTError:
return None

57
backend/src/app/main.py Normal file
View File

@ -0,0 +1,57 @@
"""Main FastAPI application."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import assets, auth, shares, uploads
from app.infra.config import get_settings
from app.infra.database import init_db
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
# Startup
await init_db()
yield
# Shutdown
pass
app = FastAPI(
title="ITCloud API",
description="Cloud photo and video storage API",
version="0.1.0",
lifespan=lifespan,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth.router, prefix="/api/v1")
app.include_router(uploads.router, prefix="/api/v1")
app.include_router(assets.router, prefix="/api/v1")
app.include_router(shares.router, prefix="/api/v1")
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {"status": "ok"}
@app.get("/")
async def root():
"""Root endpoint."""
return {"message": "ITCloud API", "version": "0.1.0"}

View File

@ -0,0 +1 @@
"""Repositories layer."""

View File

@ -0,0 +1,163 @@
"""Asset repository for database operations."""
from datetime import datetime
from typing import Optional
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Asset, AssetStatus, AssetType
class AssetRepository:
"""Repository for asset database operations."""
def __init__(self, session: AsyncSession):
"""
Initialize asset repository.
Args:
session: Database session
"""
self.session = session
async def create(
self,
user_id: str,
asset_type: AssetType,
original_filename: str,
content_type: str,
size_bytes: int,
storage_key_original: str,
) -> Asset:
"""
Create a new asset.
Args:
user_id: Owner user ID
asset_type: Type of asset (photo/video)
original_filename: Original filename
content_type: MIME type
size_bytes: File size in bytes
storage_key_original: S3 storage key
Returns:
Created asset instance
"""
asset = Asset(
user_id=user_id,
type=asset_type,
status=AssetStatus.UPLOADING,
original_filename=original_filename,
content_type=content_type,
size_bytes=size_bytes,
storage_key_original=storage_key_original,
)
self.session.add(asset)
await self.session.flush()
await self.session.refresh(asset)
return asset
async def get_by_id(self, asset_id: str) -> Optional[Asset]:
"""
Get asset by ID.
Args:
asset_id: Asset ID
Returns:
Asset instance or None if not found
"""
result = await self.session.execute(select(Asset).where(Asset.id == asset_id))
return result.scalar_one_or_none()
async def list_by_user(
self,
user_id: str,
limit: int = 50,
cursor: Optional[str] = None,
asset_type: Optional[AssetType] = None,
include_deleted: bool = False,
) -> list[Asset]:
"""
List assets for a user.
Args:
user_id: User ID
limit: Maximum number of results
cursor: Pagination cursor (asset_id)
asset_type: Filter by asset type
include_deleted: Include soft-deleted assets
Returns:
List of assets
"""
query = select(Asset).where(Asset.user_id == user_id)
if not include_deleted:
query = query.where(Asset.deleted_at.is_(None))
if asset_type:
query = query.where(Asset.type == asset_type)
if cursor:
cursor_asset = await self.get_by_id(cursor)
if cursor_asset:
query = query.where(Asset.created_at < cursor_asset.created_at)
query = query.order_by(desc(Asset.created_at)).limit(limit)
result = await self.session.execute(query)
return list(result.scalars().all())
async def update(self, asset: Asset) -> Asset:
"""
Update asset.
Args:
asset: Asset instance to update
Returns:
Updated asset instance
"""
await self.session.flush()
await self.session.refresh(asset)
return asset
async def soft_delete(self, asset: Asset) -> Asset:
"""
Soft delete an asset.
Args:
asset: Asset to delete
Returns:
Updated asset
"""
asset.deleted_at = datetime.utcnow()
asset.status = AssetStatus.DELETED
return await self.update(asset)
async def restore(self, asset: Asset) -> Asset:
"""
Restore a soft-deleted asset.
Args:
asset: Asset to restore
Returns:
Updated asset
"""
asset.deleted_at = None
asset.status = AssetStatus.READY
return await self.update(asset)
async def delete(self, asset: Asset) -> None:
"""
Permanently delete an asset.
Args:
asset: Asset to delete
"""
await self.session.delete(asset)
await self.session.flush()

View File

@ -0,0 +1,123 @@
"""Share repository for database operations."""
import secrets
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Share
class ShareRepository:
"""Repository for share database operations."""
def __init__(self, session: AsyncSession):
"""
Initialize share repository.
Args:
session: Database session
"""
self.session = session
def _generate_token(self) -> str:
"""Generate a secure random token."""
return secrets.token_urlsafe(32)
async def create(
self,
owner_user_id: str,
asset_id: Optional[str] = None,
album_id: Optional[str] = None,
expires_in_seconds: Optional[int] = None,
password_hash: Optional[str] = None,
) -> Share:
"""
Create a new share link.
Args:
owner_user_id: Owner user ID
asset_id: Optional asset ID
album_id: Optional album ID
expires_in_seconds: Optional expiration time in seconds
password_hash: Optional password hash
Returns:
Created share instance
"""
token = self._generate_token()
expires_at = None
if expires_in_seconds:
expires_at = datetime.utcnow() + timedelta(seconds=expires_in_seconds)
share = Share(
owner_user_id=owner_user_id,
asset_id=asset_id,
album_id=album_id,
token=token,
expires_at=expires_at,
password_hash=password_hash,
)
self.session.add(share)
await self.session.flush()
await self.session.refresh(share)
return share
async def get_by_id(self, share_id: str) -> Optional[Share]:
"""
Get share by ID.
Args:
share_id: Share ID
Returns:
Share instance or None if not found
"""
result = await self.session.execute(select(Share).where(Share.id == share_id))
return result.scalar_one_or_none()
async def get_by_token(self, token: str) -> Optional[Share]:
"""
Get share by token.
Args:
token: Share token
Returns:
Share instance or None if not found
"""
result = await self.session.execute(select(Share).where(Share.token == token))
return result.scalar_one_or_none()
async def revoke(self, share: Share) -> Share:
"""
Revoke a share link.
Args:
share: Share to revoke
Returns:
Updated share
"""
share.revoked_at = datetime.utcnow()
await self.session.flush()
await self.session.refresh(share)
return share
def is_valid(self, share: Share) -> bool:
"""
Check if a share is valid (not revoked or expired).
Args:
share: Share to check
Returns:
True if valid, False otherwise
"""
if share.revoked_at:
return False
if share.expires_at and share.expires_at < datetime.utcnow():
return False
return True

View File

@ -0,0 +1,78 @@
"""User repository for database operations."""
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import User
class UserRepository:
"""Repository for user database operations."""
def __init__(self, session: AsyncSession):
"""
Initialize user repository.
Args:
session: Database session
"""
self.session = session
async def create(self, email: str, password_hash: str) -> User:
"""
Create a new user.
Args:
email: User email
password_hash: Hashed password
Returns:
Created user instance
"""
user = User(email=email, password_hash=password_hash)
self.session.add(user)
await self.session.flush()
await self.session.refresh(user)
return user
async def get_by_id(self, user_id: str) -> Optional[User]:
"""
Get user by ID.
Args:
user_id: User ID
Returns:
User instance or None if not found
"""
result = await self.session.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_by_email(self, email: str) -> Optional[User]:
"""
Get user by email.
Args:
email: User email
Returns:
User instance or None if not found
"""
result = await self.session.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def update(self, user: User) -> User:
"""
Update user.
Args:
user: User instance to update
Returns:
Updated user instance
"""
await self.session.flush()
await self.session.refresh(user)
return user

View File

@ -0,0 +1 @@
"""Services layer."""

View File

@ -0,0 +1,282 @@
"""Asset management service."""
import os
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Asset, AssetStatus, AssetType
from app.infra.s3_client import S3Client
from app.repositories.asset_repository import AssetRepository
class AssetService:
"""Service for asset management operations."""
def __init__(self, session: AsyncSession, s3_client: S3Client):
"""
Initialize asset service.
Args:
session: Database session
s3_client: S3 client instance
"""
self.asset_repo = AssetRepository(session)
self.s3_client = s3_client
def _get_asset_type(self, content_type: str) -> AssetType:
"""Determine asset type from content type."""
if content_type.startswith("image/"):
return AssetType.PHOTO
elif content_type.startswith("video/"):
return AssetType.VIDEO
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Unsupported content type",
)
async def create_upload(
self,
user_id: str,
original_filename: str,
content_type: str,
size_bytes: int,
) -> tuple[Asset, dict]:
"""
Create an asset and generate pre-signed upload URL.
Args:
user_id: Owner user ID
original_filename: Original filename
content_type: MIME type
size_bytes: File size in bytes
Returns:
Tuple of (asset, presigned_post_data)
"""
asset_type = self._get_asset_type(content_type)
_, ext = os.path.splitext(original_filename)
# Create asset record
asset = await self.asset_repo.create(
user_id=user_id,
asset_type=asset_type,
original_filename=original_filename,
content_type=content_type,
size_bytes=size_bytes,
storage_key_original="", # Will be set after upload
)
# Generate storage key
storage_key = self.s3_client.generate_storage_key(
user_id=user_id,
asset_id=asset.id,
prefix="o",
extension=ext,
)
# Update asset with storage key
asset.storage_key_original = storage_key
await self.asset_repo.update(asset)
# Generate pre-signed POST
presigned_post = self.s3_client.generate_presigned_post(
storage_key=storage_key,
content_type=content_type,
max_size=size_bytes,
)
return asset, presigned_post
async def finalize_upload(
self,
user_id: str,
asset_id: str,
etag: Optional[str] = None,
sha256: Optional[str] = None,
) -> Asset:
"""
Finalize upload and mark asset as ready.
Args:
user_id: User ID
asset_id: Asset ID
etag: Optional S3 ETag
sha256: Optional file SHA256 hash
Returns:
Updated asset
Raises:
HTTPException: If asset not found or not authorized
"""
asset = await self.asset_repo.get_by_id(asset_id)
if not asset or asset.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found",
)
# Verify file was uploaded
if not self.s3_client.object_exists(asset.storage_key_original):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File not found in storage",
)
asset.status = AssetStatus.READY
if sha256:
asset.sha256 = sha256
await self.asset_repo.update(asset)
return asset
async def list_assets(
self,
user_id: str,
limit: int = 50,
cursor: Optional[str] = None,
asset_type: Optional[AssetType] = None,
) -> tuple[list[Asset], Optional[str], bool]:
"""
List user's assets.
Args:
user_id: User ID
limit: Maximum number of results
cursor: Pagination cursor
asset_type: Filter by asset type
Returns:
Tuple of (assets, next_cursor, has_more)
"""
assets = await self.asset_repo.list_by_user(
user_id=user_id,
limit=limit + 1, # Fetch one more to check if there are more
cursor=cursor,
asset_type=asset_type,
)
has_more = len(assets) > limit
if has_more:
assets = assets[:limit]
next_cursor = assets[-1].id if has_more and assets else None
return assets, next_cursor, has_more
async def get_asset(self, user_id: str, asset_id: str) -> Asset:
"""
Get asset by ID.
Args:
user_id: User ID
asset_id: Asset ID
Returns:
Asset instance
Raises:
HTTPException: If asset not found or not authorized
"""
asset = await self.asset_repo.get_by_id(asset_id)
if not asset or asset.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found",
)
return asset
async def get_download_url(
self, user_id: str, asset_id: str, kind: str = "original"
) -> str:
"""
Get pre-signed download URL for an asset.
Args:
user_id: User ID
asset_id: Asset ID
kind: 'original' or 'thumb'
Returns:
Pre-signed download URL
Raises:
HTTPException: If asset not found or not authorized
"""
asset = await self.get_asset(user_id, asset_id)
if kind == "thumb":
storage_key = asset.storage_key_thumb
if not storage_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Thumbnail not available",
)
else:
storage_key = asset.storage_key_original
return self.s3_client.generate_presigned_url(storage_key)
async def delete_asset(self, user_id: str, asset_id: str) -> Asset:
"""
Soft delete an asset.
Args:
user_id: User ID
asset_id: Asset ID
Returns:
Updated asset
"""
asset = await self.get_asset(user_id, asset_id)
return await self.asset_repo.soft_delete(asset)
async def restore_asset(self, user_id: str, asset_id: str) -> Asset:
"""
Restore a soft-deleted asset.
Args:
user_id: User ID
asset_id: Asset ID
Returns:
Updated asset
"""
asset = await self.asset_repo.get_by_id(asset_id)
if not asset or asset.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found",
)
return await self.asset_repo.restore(asset)
async def purge_asset(self, user_id: str, asset_id: str) -> None:
"""
Permanently delete an asset.
Args:
user_id: User ID
asset_id: Asset ID
"""
asset = await self.asset_repo.get_by_id(asset_id)
if not asset or asset.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found",
)
if not asset.deleted_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Asset must be deleted before purging",
)
# Delete from S3
self.s3_client.delete_object(asset.storage_key_original)
if asset.storage_key_thumb:
self.s3_client.delete_object(asset.storage_key_thumb)
# Delete from database
await self.asset_repo.delete(asset)

View File

@ -0,0 +1,96 @@
"""Authentication service."""
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import User
from app.infra.security import (
create_access_token,
create_refresh_token,
hash_password,
verify_password,
)
from app.repositories.user_repository import UserRepository
class AuthService:
"""Service for authentication operations."""
def __init__(self, session: AsyncSession):
"""
Initialize auth service.
Args:
session: Database session
"""
self.user_repo = UserRepository(session)
async def register(self, email: str, password: str) -> User:
"""
Register a new user.
Args:
email: User email
password: Plain text password
Returns:
Created user instance
Raises:
HTTPException: If email already exists
"""
existing_user = await self.user_repo.get_by_email(email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
password_hash = hash_password(password)
user = await self.user_repo.create(email=email, password_hash=password_hash)
return user
async def login(self, email: str, password: str) -> tuple[str, str]:
"""
Authenticate user and generate tokens.
Args:
email: User email
password: Plain text password
Returns:
Tuple of (access_token, refresh_token)
Raises:
HTTPException: If credentials are invalid
"""
user = await self.user_repo.get_by_email(email)
if not user or not verify_password(password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive",
)
access_token = create_access_token({"sub": user.id})
refresh_token = create_refresh_token({"sub": user.id})
return access_token, refresh_token
async def get_user_by_id(self, user_id: str) -> Optional[User]:
"""
Get user by ID.
Args:
user_id: User ID
Returns:
User instance or None
"""
return await self.user_repo.get_by_id(user_id)

View File

@ -0,0 +1,188 @@
"""Share management service."""
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Share
from app.infra.s3_client import S3Client
from app.infra.security import hash_password, verify_password
from app.repositories.asset_repository import AssetRepository
from app.repositories.share_repository import ShareRepository
class ShareService:
"""Service for share management operations."""
def __init__(self, session: AsyncSession, s3_client: S3Client):
"""
Initialize share service.
Args:
session: Database session
s3_client: S3 client instance
"""
self.share_repo = ShareRepository(session)
self.asset_repo = AssetRepository(session)
self.s3_client = s3_client
async def create_share(
self,
user_id: str,
asset_id: Optional[str] = None,
album_id: Optional[str] = None,
expires_in_seconds: Optional[int] = None,
password: Optional[str] = None,
) -> Share:
"""
Create a share link.
Args:
user_id: Owner user ID
asset_id: Optional asset ID
album_id: Optional album ID
expires_in_seconds: Optional expiration time
password: Optional password
Returns:
Created share instance
Raises:
HTTPException: If validation fails
"""
if not asset_id and not album_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Either asset_id or album_id must be provided",
)
# Verify asset ownership if provided
if asset_id:
asset = await self.asset_repo.get_by_id(asset_id)
if not asset or asset.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found",
)
password_hash = hash_password(password) if password else None
share = await self.share_repo.create(
owner_user_id=user_id,
asset_id=asset_id,
album_id=album_id,
expires_in_seconds=expires_in_seconds,
password_hash=password_hash,
)
return share
async def get_share(self, token: str, password: Optional[str] = None) -> Share:
"""
Get share by token.
Args:
token: Share token
password: Optional password for protected shares
Returns:
Share instance
Raises:
HTTPException: If share not found or invalid
"""
share = await self.share_repo.get_by_token(token)
if not share:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share not found",
)
if not self.share_repo.is_valid(share):
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="Share has expired or been revoked",
)
# Check password if required
if share.password_hash:
if not password or not verify_password(password, share.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid password",
)
return share
async def get_share_download_url(
self,
token: str,
asset_id: str,
kind: str = "original",
password: Optional[str] = None,
) -> str:
"""
Get download URL for a shared asset.
Args:
token: Share token
asset_id: Asset ID
kind: 'original' or 'thumb'
password: Optional password
Returns:
Pre-signed download URL
Raises:
HTTPException: If share invalid or asset not shared
"""
share = await self.get_share(token, password)
# Verify asset is part of the share
if share.asset_id and share.asset_id != asset_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Asset not part of this share",
)
asset = await self.asset_repo.get_by_id(asset_id)
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found",
)
if kind == "thumb":
storage_key = asset.storage_key_thumb
if not storage_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Thumbnail not available",
)
else:
storage_key = asset.storage_key_original
return self.s3_client.generate_presigned_url(storage_key)
async def revoke_share(self, user_id: str, share_id: str) -> Share:
"""
Revoke a share link.
Args:
user_id: User ID
share_id: Share ID
Returns:
Updated share
Raises:
HTTPException: If share not found or not authorized
"""
share = await self.share_repo.get_by_id(share_id)
if not share or share.owner_user_id != user_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Share not found",
)
return await self.share_repo.revoke(share)

83
docker-compose.yml Normal file
View File

@ -0,0 +1,83 @@
version: '3.8'
services:
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- APP_ENV=dev
- DATABASE_URL=sqlite+aiosqlite:////app/data/app.db
- S3_ENDPOINT_URL=http://minio:9000
- S3_REGION=us-east-1
- S3_ACCESS_KEY_ID=minioadmin
- S3_SECRET_ACCESS_KEY=minioadmin
- MEDIA_BUCKET=itcloud-media
- JWT_SECRET=dev-secret-key-change-in-production
- CORS_ORIGINS=http://localhost:5173,http://localhost:3000
- REDIS_URL=redis://redis:6379/0
volumes:
- ./backend/src:/app/src
- backend-data:/app/data
depends_on:
- minio
- redis
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
volumes:
- minio-data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
minio-setup:
image: minio/mc:latest
depends_on:
- minio
entrypoint: >
/bin/sh -c "
sleep 5;
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
/usr/bin/mc mb myminio/itcloud-media --ignore-existing;
/usr/bin/mc anonymous set none myminio/itcloud-media;
exit 0;
"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build: ./frontend
ports:
- "5173:5173"
environment:
- VITE_API_URL=http://localhost:8000
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
command: npm run dev -- --host 0.0.0.0
volumes:
backend-data:
minio-data:
redis-data:

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

17
frontend/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy application code
COPY . .
# Expose port
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

14
frontend/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Облачное хранилище фото и видео" />
<title>ITCloud - Облачное хранилище</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

36
frontend/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "itcloud-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.6",
"@mui/material": "^5.15.6",
"axios": "^1.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-router-dom": "^6.21.3",
"react-virtuoso": "^4.6.2"
},
"devDependencies": {
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

47
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,47 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { Box } from '@mui/material';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import LibraryPage from './pages/LibraryPage';
import TrashPage from './pages/TrashPage';
import ShareViewPage from './pages/ShareViewPage';
import { useAuth } from './hooks/useAuth';
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <Box>Loading...</Box>;
}
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
}
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/share/:token" element={<ShareViewPage />} />
<Route
path="/library"
element={
<PrivateRoute>
<LibraryPage />
</PrivateRoute>
}
/>
<Route
path="/trash"
element={
<PrivateRoute>
<TrashPage />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/library" />} />
</Routes>
);
}
export default App;

View File

@ -0,0 +1,160 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import {
AppBar,
Box,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import {
Menu as MenuIcon,
CloudUpload as CloudIcon,
PhotoLibrary as LibraryIcon,
Delete as TrashIcon,
Logout as LogoutIcon,
} from '@mui/icons-material';
import { useAuth } from '../hooks/useAuth';
const drawerWidth = 240;
interface LayoutProps {
children: React.ReactNode;
}
export default function Layout({ children }: LayoutProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [mobileOpen, setMobileOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { logout } = useAuth();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
const handleNavigation = (path: string) => {
navigate(path);
if (isMobile) {
setMobileOpen(false);
}
};
const handleLogout = () => {
logout();
navigate('/login');
};
const menuItems = [
{ text: 'Библиотека', icon: <LibraryIcon />, path: '/library' },
{ text: 'Корзина', icon: <TrashIcon />, path: '/trash' },
];
const drawer = (
<Box>
<Toolbar>
<CloudIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6" fontWeight="bold">
ITCloud
</Typography>
</Toolbar>
<List>
{menuItems.map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton
selected={location.pathname === item.path}
onClick={() => handleNavigation(item.path)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</ListItem>
))}
<ListItem disablePadding>
<ListItemButton onClick={handleLogout}>
<ListItemIcon>
<LogoutIcon />
</ListItemIcon>
<ListItemText primary="Выйти" />
</ListItemButton>
</ListItem>
</List>
</Box>
);
return (
<Box sx={{ display: 'flex', height: '100vh' }}>
<AppBar
position="fixed"
sx={{
width: { sm: `calc(100% - ${drawerWidth}px)` },
ml: { sm: `${drawerWidth}px` },
}}
>
<Toolbar>
<IconButton
color="inherit"
edge="start"
onClick={handleDrawerToggle}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
{menuItems.find((item) => item.path === location.pathname)?.text || 'ITCloud'}
</Typography>
</Toolbar>
</AppBar>
<Box
component="nav"
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={handleDrawerToggle}
ModalProps={{ keepMounted: true }}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
}}
open
>
{drawer}
</Drawer>
</Box>
<Box
component="main"
sx={{
flexGrow: 1,
width: { sm: `calc(100% - ${drawerWidth}px)` },
height: '100vh',
overflow: 'hidden',
}}
>
<Toolbar />
{children}
</Box>
</Box>
);
}

View File

@ -0,0 +1,193 @@
import { useState, useEffect } from 'react';
import {
Card,
CardMedia,
CardActionArea,
Box,
IconButton,
CircularProgress,
Typography,
Checkbox,
} from '@mui/material';
import {
PlayCircleOutline as VideoIcon,
CheckCircle as CheckedIcon,
} from '@mui/icons-material';
import type { Asset } from '../types';
import api from '../services/api';
interface MediaCardProps {
asset: Asset;
selected?: boolean;
onSelect?: (assetId: string, selected: boolean) => void;
onClick?: () => void;
}
export default function MediaCard({ asset, selected, onSelect, onClick }: MediaCardProps) {
const [thumbnailUrl, setThumbnailUrl] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
loadThumbnail();
}, [asset.id]);
const loadThumbnail = async () => {
try {
setLoading(true);
setError(false);
// Try to get thumbnail first, fallback to original for photos
const url = asset.storage_key_thumb
? await api.getDownloadUrl(asset.id, 'thumb')
: asset.type === 'photo'
? await api.getDownloadUrl(asset.id, 'original')
: '';
setThumbnailUrl(url);
} catch (err) {
console.error('Failed to load thumbnail:', err);
setError(true);
} finally {
setLoading(false);
}
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
};
const handleSelect = (e: React.MouseEvent) => {
e.stopPropagation();
if (onSelect) {
onSelect(asset.id, !selected);
}
};
return (
<Card
sx={{
position: 'relative',
aspectRatio: '1',
borderRadius: 2,
overflow: 'hidden',
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 4,
},
}}
>
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
{loading && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
bgcolor: 'grey.200',
}}
>
<CircularProgress />
</Box>
)}
{error && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
bgcolor: 'grey.300',
}}
>
<Typography color="error">Ошибка загрузки</Typography>
</Box>
)}
{!loading && !error && thumbnailUrl && (
<CardMedia
component="img"
image={thumbnailUrl}
alt={asset.original_filename}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
)}
{asset.type === 'video' && (
<Box
sx={{
position: 'absolute',
top: 8,
right: 8,
color: 'white',
bgcolor: 'rgba(0,0,0,0.5)',
borderRadius: '50%',
}}
>
<VideoIcon fontSize="large" />
</Box>
)}
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bgcolor: 'rgba(0,0,0,0.6)',
color: 'white',
p: 1,
}}
>
<Typography variant="caption" display="block" noWrap>
{asset.original_filename}
</Typography>
<Typography variant="caption" display="block">
{formatFileSize(asset.size_bytes)} {formatDate(asset.created_at)}
</Typography>
</Box>
{onSelect && (
<Checkbox
checked={selected}
onClick={handleSelect}
icon={
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
border: '2px solid white',
bgcolor: 'rgba(0,0,0,0.3)',
}}
/>
}
checkedIcon={<CheckedIcon sx={{ color: 'primary.main' }} />}
sx={{
position: 'absolute',
top: 8,
left: 8,
}}
/>
)}
</CardActionArea>
</Card>
);
}

View File

@ -0,0 +1,257 @@
import { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
LinearProgress,
List,
ListItem,
ListItemText,
IconButton,
Alert,
} from '@mui/material';
import {
CloudUpload as UploadIcon,
Close as CloseIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
} from '@mui/icons-material';
import api from '../services/api';
interface UploadFile {
file: File;
progress: number;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
assetId?: string;
}
interface UploadDialogProps {
open: boolean;
onClose: () => void;
onComplete?: () => void;
}
export default function UploadDialog({ open, onClose, onComplete }: UploadDialogProps) {
const [files, setFiles] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const onDrop = useCallback((acceptedFiles: File[]) => {
const newFiles: UploadFile[] = acceptedFiles.map((file) => ({
file,
progress: 0,
status: 'pending',
}));
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
'video/*': ['.mp4', '.mov', '.avi', '.mkv', '.webm'],
},
});
const updateFileProgress = (index: number, progress: number, status: UploadFile['status'], error?: string, assetId?: string) => {
setFiles((prev) =>
prev.map((f, i) =>
i === index ? { ...f, progress, status, error, assetId } : f
)
);
};
const uploadFile = async (file: File, index: number) => {
try {
updateFileProgress(index, 0, 'uploading');
// Step 1: Create upload
const uploadData = await api.createUpload({
original_filename: file.name,
content_type: file.type,
size_bytes: file.size,
});
updateFileProgress(index, 33, 'uploading', undefined, uploadData.asset_id);
// Step 2: Upload to S3
await api.uploadToS3(uploadData.upload_url, file, uploadData.fields);
updateFileProgress(index, 66, 'uploading', undefined, uploadData.asset_id);
// Step 3: Finalize upload
await api.finalizeUpload(uploadData.asset_id);
updateFileProgress(index, 100, 'success', undefined, uploadData.asset_id);
} catch (error: any) {
console.error('Upload failed:', error);
updateFileProgress(
index,
0,
'error',
error.response?.data?.detail || 'Ошибка загрузки'
);
}
};
const handleUpload = async () => {
setUploading(true);
// Upload files in parallel (max 3 at a time)
const batchSize = 3;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
await Promise.all(
batch.map((file, batchIndex) => {
const fileIndex = i + batchIndex;
if (files[fileIndex].status === 'pending') {
return uploadFile(files[fileIndex].file, fileIndex);
}
return Promise.resolve();
})
);
}
setUploading(false);
if (onComplete) {
onComplete();
}
};
const handleClose = () => {
if (!uploading) {
setFiles([]);
onClose();
}
};
const canUpload = files.length > 0 && files.some((f) => f.status === 'pending');
const allComplete = files.length > 0 && files.every((f) => f.status === 'success' || f.status === 'error');
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>
Загрузка файлов
<IconButton
onClick={handleClose}
disabled={uploading}
sx={{ position: 'absolute', right: 8, top: 8 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
{files.length === 0 && (
<Box
{...getRootProps()}
sx={{
border: '2px dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.400',
borderRadius: 2,
p: 4,
textAlign: 'center',
cursor: 'pointer',
bgcolor: isDragActive ? 'action.hover' : 'transparent',
transition: 'all 0.3s',
'&:hover': {
bgcolor: 'action.hover',
borderColor: 'primary.main',
},
}}
>
<input {...getInputProps()} />
<UploadIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
{isDragActive
? 'Отпустите файлы для загрузки'
: 'Перетащите файлы сюда'}
</Typography>
<Typography variant="body2" color="text.secondary">
или нажмите для выбора файлов
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 2 }}>
Поддерживаются фото (JPG, PNG, GIF, WebP) и видео (MP4, MOV, AVI, MKV, WebM)
</Typography>
</Box>
)}
{files.length > 0 && (
<List sx={{ maxHeight: 400, overflow: 'auto' }}>
{files.map((uploadFile, index) => (
<ListItem key={index} sx={{ flexDirection: 'column', alignItems: 'stretch' }}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', mb: 1 }}>
<ListItemText
primary={uploadFile.file.name}
secondary={`${(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB`}
/>
{uploadFile.status === 'success' && (
<SuccessIcon color="success" />
)}
{uploadFile.status === 'error' && (
<ErrorIcon color="error" />
)}
</Box>
{uploadFile.status === 'uploading' && (
<LinearProgress
variant="determinate"
value={uploadFile.progress}
sx={{ mb: 1 }}
/>
)}
{uploadFile.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{uploadFile.error}
</Alert>
)}
</ListItem>
))}
</List>
)}
{files.length > 0 && !allComplete && (
<Box
{...getRootProps()}
sx={{
mt: 2,
p: 2,
border: '1px dashed',
borderColor: 'grey.400',
borderRadius: 1,
textAlign: 'center',
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
}}
>
<input {...getInputProps()} />
<Typography variant="body2" color="text.secondary">
Добавить еще файлы
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={uploading}>
{allComplete ? 'Закрыть' : 'Отмена'}
</Button>
{canUpload && (
<Button
onClick={handleUpload}
variant="contained"
disabled={uploading}
>
Загрузить ({files.filter((f) => f.status === 'pending').length})
</Button>
)}
</DialogActions>
</Dialog>
);
}

View File

@ -0,0 +1,273 @@
import { useState, useEffect } from 'react';
import {
Dialog,
Box,
IconButton,
CircularProgress,
Typography,
} from '@mui/material';
import {
Close as CloseIcon,
NavigateBefore as PrevIcon,
NavigateNext as NextIcon,
Download as DownloadIcon,
Share as ShareIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import type { Asset } from '../types';
import api from '../services/api';
interface ViewerModalProps {
asset: Asset | null;
assets: Asset[];
onClose: () => void;
onDelete?: (assetId: string) => void;
onShare?: (assetId: string) => void;
}
export default function ViewerModal({
asset,
assets,
onClose,
onDelete,
onShare,
}: ViewerModalProps) {
const [currentUrl, setCurrentUrl] = useState<string>('');
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(-1);
useEffect(() => {
if (asset) {
const index = assets.findIndex((a) => a.id === asset.id);
setCurrentIndex(index);
loadMedia(asset);
}
}, [asset]);
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (!asset) return;
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'ArrowLeft') {
handlePrev();
} else if (e.key === 'ArrowRight') {
handleNext();
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [asset, currentIndex]);
const loadMedia = async (asset: Asset) => {
try {
setLoading(true);
const url = await api.getDownloadUrl(asset.id, 'original');
setCurrentUrl(url);
} catch (error) {
console.error('Failed to load media:', error);
} finally {
setLoading(false);
}
};
const handlePrev = () => {
if (currentIndex > 0) {
const prevAsset = assets[currentIndex - 1];
loadMedia(prevAsset);
setCurrentIndex(currentIndex - 1);
}
};
const handleNext = () => {
if (currentIndex < assets.length - 1) {
const nextAsset = assets[currentIndex + 1];
loadMedia(nextAsset);
setCurrentIndex(currentIndex + 1);
}
};
const handleDownload = () => {
if (currentUrl && asset) {
const link = document.createElement('a');
link.href = currentUrl;
link.download = asset.original_filename;
link.click();
}
};
const handleDelete = () => {
if (asset && onDelete) {
onDelete(asset.id);
onClose();
}
};
const handleShare = () => {
if (asset && onShare) {
onShare(asset.id);
}
};
if (!asset) return null;
return (
<Dialog
open={!!asset}
onClose={onClose}
maxWidth={false}
fullWidth
PaperProps={{
sx: {
bgcolor: 'black',
m: 0,
maxWidth: '100vw',
maxHeight: '100vh',
height: '100vh',
},
}}
>
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Top bar */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
p: 2,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
bgcolor: 'rgba(0,0,0,0.5)',
zIndex: 1,
}}
>
<Typography variant="h6" color="white" noWrap sx={{ flex: 1, mr: 2 }}>
{asset.original_filename}
</Typography>
<Box>
<IconButton color="inherit" onClick={handleDownload} sx={{ color: 'white' }}>
<DownloadIcon />
</IconButton>
{onShare && (
<IconButton color="inherit" onClick={handleShare} sx={{ color: 'white' }}>
<ShareIcon />
</IconButton>
)}
{onDelete && (
<IconButton color="inherit" onClick={handleDelete} sx={{ color: 'white' }}>
<DeleteIcon />
</IconButton>
)}
<IconButton onClick={onClose} sx={{ color: 'white' }}>
<CloseIcon />
</IconButton>
</Box>
</Box>
{/* Navigation buttons */}
{currentIndex > 0 && (
<IconButton
onClick={handlePrev}
sx={{
position: 'absolute',
left: 16,
color: 'white',
bgcolor: 'rgba(0,0,0,0.5)',
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
}}
>
<PrevIcon fontSize="large" />
</IconButton>
)}
{currentIndex < assets.length - 1 && (
<IconButton
onClick={handleNext}
sx={{
position: 'absolute',
right: 16,
color: 'white',
bgcolor: 'rgba(0,0,0,0.5)',
'&:hover': { bgcolor: 'rgba(0,0,0,0.7)' },
}}
>
<NextIcon fontSize="large" />
</IconButton>
)}
{/* Content */}
{loading && (
<CircularProgress sx={{ color: 'white' }} />
)}
{!loading && asset.type === 'photo' && (
<Box
component="img"
src={currentUrl}
alt={asset.original_filename}
sx={{
maxWidth: '95%',
maxHeight: '85%',
objectFit: 'contain',
}}
/>
)}
{!loading && asset.type === 'video' && (
<Box
component="video"
src={currentUrl}
controls
autoPlay
sx={{
maxWidth: '95%',
maxHeight: '85%',
}}
/>
)}
{/* Bottom info */}
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
p: 2,
bgcolor: 'rgba(0,0,0,0.5)',
color: 'white',
display: 'flex',
justifyContent: 'center',
gap: 2,
}}
>
<Typography variant="body2">
{currentIndex + 1} / {assets.length}
</Typography>
<Typography variant="body2">
{(asset.size_bytes / 1024 / 1024).toFixed(2)} MB
</Typography>
{asset.width && asset.height && (
<Typography variant="body2">
{asset.width} × {asset.height}
</Typography>
)}
</Box>
</Box>
</Dialog>
);
}

View File

@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import api from '../services/api';
import type { User } from '../types';
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
checkAuth();
}, []);
const checkAuth = async () => {
const token = localStorage.getItem('access_token');
if (!token) {
setLoading(false);
return;
}
try {
const userData = await api.getMe();
setUser(userData);
setIsAuthenticated(true);
} catch (error) {
console.error('Auth check failed:', error);
setIsAuthenticated(false);
setUser(null);
} finally {
setLoading(false);
}
};
const login = async (email: string, password: string) => {
await api.login(email, password);
await checkAuth();
};
const register = async (email: string, password: string) => {
await api.register(email, password);
};
const logout = () => {
api.logout();
setUser(null);
setIsAuthenticated(false);
};
return {
user,
loading,
isAuthenticated,
login,
register,
logout,
};
}

17
frontend/src/index.css Normal file
View File

@ -0,0 +1,17 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
font-family: 'Roboto', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

18
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { CssBaseline, ThemeProvider } from '@mui/material'
import App from './App'
import theme from './theme/theme'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</BrowserRouter>
</React.StrictMode>,
)

View File

@ -0,0 +1,251 @@
import { useState, useEffect } from 'react';
import {
Box,
Grid,
Fab,
Typography,
CircularProgress,
Button,
Select,
MenuItem,
FormControl,
InputLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Alert,
Snackbar,
} from '@mui/material';
import { Add as AddIcon, FilterList as FilterIcon } from '@mui/icons-material';
import Layout from '../components/Layout';
import MediaCard from '../components/MediaCard';
import UploadDialog from '../components/UploadDialog';
import ViewerModal from '../components/ViewerModal';
import type { Asset, AssetType } from '../types';
import api from '../services/api';
export default function LibraryPage() {
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState<string | undefined>();
const [filter, setFilter] = useState<AssetType | 'all'>('all');
const [uploadOpen, setUploadOpen] = useState(false);
const [viewerAsset, setViewerAsset] = useState<Asset | null>(null);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [shareAssetId, setShareAssetId] = useState<string>('');
const [shareLink, setShareLink] = useState<string>('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
useEffect(() => {
loadAssets(true);
}, [filter]);
const loadAssets = async (reset: boolean = false) => {
try {
setLoading(true);
const response = await api.listAssets({
cursor: reset ? undefined : cursor,
limit: 50,
type: filter === 'all' ? undefined : filter,
});
setAssets(reset ? response.items : [...assets, ...response.items]);
setHasMore(response.has_more);
setCursor(response.next_cursor);
} catch (error) {
console.error('Failed to load assets:', error);
} finally {
setLoading(false);
}
};
const handleUploadComplete = () => {
setUploadOpen(false);
loadAssets(true);
showSnackbar('Файлы успешно загружены');
};
const handleDelete = async (assetId: string) => {
try {
await api.deleteAsset(assetId);
setAssets(assets.filter((a) => a.id !== assetId));
showSnackbar('Файл перемещен в корзину');
} catch (error) {
console.error('Failed to delete asset:', error);
showSnackbar('Ошибка при удалении файла');
}
};
const handleShare = (assetId: string) => {
setShareAssetId(assetId);
setShareDialogOpen(true);
};
const handleCreateShare = async () => {
try {
const share = await api.createShare({
asset_id: shareAssetId,
expires_in_seconds: 86400 * 7, // 7 days
});
const link = `${window.location.origin}/share/${share.token}`;
setShareLink(link);
showSnackbar('Ссылка создана');
} catch (error) {
console.error('Failed to create share:', error);
showSnackbar('Ошибка создания ссылки');
}
};
const handleCopyShareLink = () => {
navigator.clipboard.writeText(shareLink);
showSnackbar('Ссылка скопирована');
setShareDialogOpen(false);
setShareLink('');
};
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarOpen(true);
};
return (
<Layout>
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Filters */}
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Тип файлов</InputLabel>
<Select
value={filter}
label="Тип файлов"
onChange={(e) => setFilter(e.target.value as AssetType | 'all')}
>
<MenuItem value="all">Все файлы</MenuItem>
<MenuItem value="photo">Фото</MenuItem>
<MenuItem value="video">Видео</MenuItem>
</Select>
</FormControl>
</Box>
{/* Content */}
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
{loading && assets.length === 0 && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
)}
{!loading && assets.length === 0 && (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Нет файлов
</Typography>
<Typography variant="body2" color="text.secondary">
Нажмите кнопку + чтобы загрузить файлы
</Typography>
</Box>
)}
{assets.length > 0 && (
<>
<Grid container spacing={2}>
{assets.map((asset) => (
<Grid item xs={6} sm={4} md={3} lg={2} key={asset.id}>
<MediaCard
asset={asset}
onClick={() => setViewerAsset(asset)}
/>
</Grid>
))}
</Grid>
{hasMore && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
<Button
variant="outlined"
onClick={() => loadAssets(false)}
disabled={loading}
>
{loading ? 'Загрузка...' : 'Загрузить еще'}
</Button>
</Box>
)}
</>
)}
</Box>
{/* FAB */}
<Fab
color="primary"
sx={{ position: 'fixed', bottom: 24, right: 24 }}
onClick={() => setUploadOpen(true)}
>
<AddIcon />
</Fab>
{/* Upload Dialog */}
<UploadDialog
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onComplete={handleUploadComplete}
/>
{/* Viewer Modal */}
<ViewerModal
asset={viewerAsset}
assets={assets}
onClose={() => setViewerAsset(null)}
onDelete={handleDelete}
onShare={handleShare}
/>
{/* Share Dialog */}
<Dialog open={shareDialogOpen} onClose={() => setShareDialogOpen(false)}>
<DialogTitle>Поделиться файлом</DialogTitle>
<DialogContent>
{!shareLink ? (
<Typography>
Создать публичную ссылку на файл? Ссылка будет действительна 7 дней.
</Typography>
) : (
<TextField
fullWidth
value={shareLink}
label="Ссылка для доступа"
margin="normal"
InputProps={{
readOnly: true,
}}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setShareDialogOpen(false)}>Отмена</Button>
{!shareLink ? (
<Button onClick={handleCreateShare} variant="contained">
Создать ссылку
</Button>
) : (
<Button onClick={handleCopyShareLink} variant="contained">
Скопировать
</Button>
)}
</DialogActions>
</Dialog>
{/* Snackbar */}
<Snackbar
open={snackbarOpen}
autoHideDuration={3000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMessage}
/>
</Box>
</Layout>
);
}

View File

@ -0,0 +1,117 @@
import { useState } from 'react';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import {
Box,
Container,
Paper,
TextField,
Button,
Typography,
Link,
Alert,
} from '@mui/material';
import { CloudUpload as CloudIcon } from '@mui/icons-material';
import { useAuth } from '../hooks/useAuth';
export default function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
navigate('/library');
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка входа. Проверьте данные.');
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
<Container maxWidth="sm">
<Paper
elevation={24}
sx={{
p: 4,
borderRadius: 3,
}}
>
<Box sx={{ textAlign: 'center', mb: 3 }}>
<CloudIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" fontWeight="bold" gutterBottom>
ITCloud
</Typography>
<Typography variant="body1" color="text.secondary">
Облачное хранилище фото и видео
</Typography>
</Box>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
margin="normal"
required
autoFocus
/>
<TextField
fullWidth
label="Пароль"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
required
/>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? 'Вход...' : 'Войти'}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2">
Нет аккаунта?{' '}
<Link component={RouterLink} to="/register" underline="hover">
Зарегистрироваться
</Link>
</Typography>
</Box>
</form>
</Paper>
</Container>
</Box>
);
}

View File

@ -0,0 +1,139 @@
import { useState } from 'react';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import {
Box,
Container,
Paper,
TextField,
Button,
Typography,
Link,
Alert,
} from '@mui/material';
import { CloudUpload as CloudIcon } from '@mui/icons-material';
import { useAuth } from '../hooks/useAuth';
export default function RegisterPage() {
const navigate = useNavigate();
const { register } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Пароли не совпадают');
return;
}
if (password.length < 8) {
setError('Пароль должен быть не менее 8 символов');
return;
}
setLoading(true);
try {
await register(email, password);
navigate('/login');
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка регистрации');
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}
>
<Container maxWidth="sm">
<Paper
elevation={24}
sx={{
p: 4,
borderRadius: 3,
}}
>
<Box sx={{ textAlign: 'center', mb: 3 }}>
<CloudIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" fontWeight="bold" gutterBottom>
Регистрация
</Typography>
<Typography variant="body1" color="text.secondary">
Создайте аккаунт для доступа к облачному хранилищу
</Typography>
</Box>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
margin="normal"
required
autoFocus
/>
<TextField
fullWidth
label="Пароль"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
required
helperText="Минимум 8 символов"
/>
<TextField
fullWidth
label="Подтверждение пароля"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
margin="normal"
required
/>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2">
Уже есть аккаунт?{' '}
<Link component={RouterLink} to="/login" underline="hover">
Войти
</Link>
</Typography>
</Box>
</form>
</Paper>
</Container>
</Box>
);
}

View File

@ -0,0 +1,172 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
Box,
Container,
Paper,
Typography,
CircularProgress,
Alert,
TextField,
Button,
} from '@mui/material';
import { CloudUpload as CloudIcon } from '@mui/icons-material';
import ViewerModal from '../components/ViewerModal';
import type { Share, Asset } from '../types';
import api from '../services/api';
export default function ShareViewPage() {
const { token } = useParams<{ token: string }>();
const [share, setShare] = useState<Share | null>(null);
const [asset, setAsset] = useState<Asset | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [password, setPassword] = useState('');
const [needsPassword, setNeedsPassword] = useState(false);
const [viewerOpen, setViewerOpen] = useState(false);
useEffect(() => {
if (token) {
loadShare();
}
}, [token]);
const loadShare = async (pwd?: string) => {
if (!token) return;
try {
setLoading(true);
setError('');
const shareData = await api.getShare(token, pwd);
setShare(shareData);
if (shareData.asset_id) {
const assetData = await api.getAsset(shareData.asset_id);
setAsset(assetData);
}
} catch (err: any) {
if (err.response?.status === 401) {
setNeedsPassword(true);
setError('Требуется пароль');
} else {
setError(err.response?.data?.detail || 'Ссылка недействительна или истекла');
}
} finally {
setLoading(false);
}
};
const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault();
loadShare(password);
};
const handleView = () => {
setViewerOpen(true);
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
p: 2,
}}
>
<Container maxWidth="md">
<Paper
elevation={24}
sx={{
p: 4,
borderRadius: 3,
}}
>
<Box sx={{ textAlign: 'center', mb: 3 }}>
<CloudIcon sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h4" fontWeight="bold" gutterBottom>
Общий доступ к файлу
</Typography>
</Box>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
)}
{error && !needsPassword && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{needsPassword && !share && (
<form onSubmit={handlePasswordSubmit}>
<Typography variant="body1" gutterBottom>
Этот файл защищен паролем
</Typography>
<TextField
fullWidth
type="password"
label="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
margin="normal"
required
autoFocus
/>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
<Button
type="submit"
fullWidth
variant="contained"
size="large"
sx={{ mt: 3 }}
>
Открыть
</Button>
</form>
)}
{!loading && share && asset && (
<Box>
<Typography variant="h6" gutterBottom>
{asset.original_filename}
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Размер: {(asset.size_bytes / 1024 / 1024).toFixed(2)} MB
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Тип: {asset.type === 'photo' ? 'Фото' : 'Видео'}
</Typography>
<Button
fullWidth
variant="contained"
size="large"
sx={{ mt: 3 }}
onClick={handleView}
>
Просмотреть
</Button>
</Box>
)}
</Paper>
{asset && (
<ViewerModal
asset={viewerOpen ? asset : null}
assets={[asset]}
onClose={() => setViewerOpen(false)}
/>
)}
</Container>
</Box>
);
}

View File

@ -0,0 +1,154 @@
import { useState, useEffect } from 'react';
import {
Box,
Grid,
Typography,
CircularProgress,
Button,
Snackbar,
} from '@mui/material';
import Layout from '../components/Layout';
import MediaCard from '../components/MediaCard';
import type { Asset } from '../types';
import api from '../services/api';
export default function TrashPage() {
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const [selectedAssets, setSelectedAssets] = useState<Set<string>>(new Set());
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
useEffect(() => {
loadDeletedAssets();
}, []);
const loadDeletedAssets = async () => {
try {
setLoading(true);
// We need to get all assets and filter deleted ones
// TODO: Add deleted filter to API
const response = await api.listAssets({ limit: 200 });
const deletedAssets = response.items.filter((asset) => asset.deleted_at);
setAssets(deletedAssets);
} catch (error) {
console.error('Failed to load deleted assets:', error);
} finally {
setLoading(false);
}
};
const handleSelect = (assetId: string, selected: boolean) => {
const newSelected = new Set(selectedAssets);
if (selected) {
newSelected.add(assetId);
} else {
newSelected.delete(assetId);
}
setSelectedAssets(newSelected);
};
const handleRestore = async () => {
if (selectedAssets.size === 0) return;
try {
await Promise.all(
Array.from(selectedAssets).map((assetId) => api.restoreAsset(assetId))
);
setAssets(assets.filter((a) => !selectedAssets.has(a.id)));
setSelectedAssets(new Set());
showSnackbar('Файлы восстановлены');
} catch (error) {
console.error('Failed to restore assets:', error);
showSnackbar('Ошибка при восстановлении файлов');
}
};
const handlePurge = async () => {
if (selectedAssets.size === 0) return;
if (!confirm('Вы уверены? Файлы будут удалены навсегда.')) {
return;
}
try {
await Promise.all(
Array.from(selectedAssets).map((assetId) => api.purgeAsset(assetId))
);
setAssets(assets.filter((a) => !selectedAssets.has(a.id)));
setSelectedAssets(new Set());
showSnackbar('Файлы удалены навсегда');
} catch (error) {
console.error('Failed to purge assets:', error);
showSnackbar('Ошибка при удалении файлов');
}
};
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarOpen(true);
};
return (
<Layout>
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Actions */}
{selectedAssets.size > 0 && (
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider', display: 'flex', gap: 2 }}>
<Typography variant="body1" sx={{ flexGrow: 1, alignSelf: 'center' }}>
Выбрано: {selectedAssets.size}
</Typography>
<Button variant="outlined" onClick={handleRestore}>
Восстановить
</Button>
<Button variant="outlined" color="error" onClick={handlePurge}>
Удалить навсегда
</Button>
</Box>
)}
{/* Content */}
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
)}
{!loading && assets.length === 0 && (
<Box sx={{ textAlign: 'center', p: 4 }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Корзина пуста
</Typography>
<Typography variant="body2" color="text.secondary">
Удаленные файлы будут отображаться здесь
</Typography>
</Box>
)}
{assets.length > 0 && (
<Grid container spacing={2}>
{assets.map((asset) => (
<Grid item xs={6} sm={4} md={3} lg={2} key={asset.id}>
<MediaCard
asset={asset}
selected={selectedAssets.has(asset.id)}
onSelect={handleSelect}
/>
</Grid>
))}
</Grid>
)}
</Box>
{/* Snackbar */}
<Snackbar
open={snackbarOpen}
autoHideDuration={3000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMessage}
/>
</Box>
</Layout>
);
}

View File

@ -0,0 +1,175 @@
import axios, { AxiosInstance } from 'axios';
import type {
User,
AuthTokens,
Asset,
AssetListResponse,
CreateUploadRequest,
CreateUploadResponse,
DownloadUrlResponse,
Share,
CreateShareRequest,
} from '../types';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: `${API_URL}/api/v1`,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token to requests
this.client.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 errors
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
// Auth
async register(email: string, password: string): Promise<User> {
const { data } = await this.client.post('/auth/register', { email, password });
return data;
}
async login(email: string, password: string): Promise<AuthTokens> {
const { data } = await this.client.post('/auth/login', { email, password });
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
return data;
}
async getMe(): Promise<User> {
const { data } = await this.client.get('/auth/me');
return data;
}
logout(): void {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
// Assets
async listAssets(params?: {
cursor?: string;
limit?: number;
type?: string;
}): Promise<AssetListResponse> {
const { data } = await this.client.get('/assets', { params });
return data;
}
async getAsset(assetId: string): Promise<Asset> {
const { data } = await this.client.get(`/assets/${assetId}`);
return data;
}
async getDownloadUrl(assetId: string, kind: 'original' | 'thumb' = 'original'): Promise<string> {
const { data } = await this.client.get<DownloadUrlResponse>(
`/assets/${assetId}/download-url`,
{ params: { kind } }
);
return data.url;
}
async deleteAsset(assetId: string): Promise<Asset> {
const { data } = await this.client.delete(`/assets/${assetId}`);
return data;
}
async restoreAsset(assetId: string): Promise<Asset> {
const { data } = await this.client.post(`/assets/${assetId}/restore`);
return data;
}
async purgeAsset(assetId: string): Promise<void> {
await this.client.delete(`/assets/${assetId}/purge`);
}
// Upload
async createUpload(request: CreateUploadRequest): Promise<CreateUploadResponse> {
const { data } = await this.client.post('/uploads/create', request);
return data;
}
async uploadToS3(url: string, file: File, fields?: Record<string, string>): Promise<void> {
const formData = new FormData();
// Add fields first (for pre-signed POST)
if (fields) {
Object.entries(fields).forEach(([key, value]) => {
formData.append(key, value);
});
}
// Add file last
formData.append('file', file);
await axios.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
async finalizeUpload(assetId: string, etag?: string, sha256?: string): Promise<Asset> {
const { data } = await this.client.post(`/uploads/${assetId}/finalize`, { etag, sha256 });
return data;
}
// Shares
async createShare(request: CreateShareRequest): Promise<Share> {
const { data } = await this.client.post('/shares', request);
return data;
}
async getShare(token: string, password?: string): Promise<Share> {
const { data } = await this.client.get(`/shares/${token}`, {
params: password ? { password } : undefined,
});
return data;
}
async getShareDownloadUrl(
token: string,
assetId: string,
kind: 'original' | 'thumb' = 'original',
password?: string
): Promise<string> {
const { data } = await this.client.get<DownloadUrlResponse>(
`/shares/${token}/download-url`,
{
params: { asset_id: assetId, kind, password },
}
);
return data.url;
}
async revokeShare(token: string): Promise<Share> {
const { data } = await this.client.post(`/shares/${token}/revoke`);
return data;
}
}
export default new ApiClient();

View File

@ -0,0 +1,53 @@
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
mode: 'light',
primary: {
main: '#1976d2',
light: '#42a5f5',
dark: '#1565c0',
},
secondary: {
main: '#9c27b0',
light: '#ba68c8',
dark: '#7b1fa2',
},
background: {
default: '#f5f5f5',
paper: '#ffffff',
},
},
typography: {
fontFamily: [
'Roboto',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Arial',
'sans-serif',
].join(','),
},
shape: {
borderRadius: 8,
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 500,
},
},
},
MuiCard: {
styleOverrides: {
root: {
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
},
},
},
},
});
export default theme;

View File

@ -0,0 +1,76 @@
export interface User {
id: string;
email: string;
is_active: boolean;
created_at: string;
}
export interface AuthTokens {
access_token: string;
refresh_token: string;
token_type: string;
}
export type AssetType = 'photo' | 'video';
export type AssetStatus = 'uploading' | 'ready' | 'failed' | 'deleted';
export interface Asset {
id: string;
user_id: string;
type: AssetType;
status: AssetStatus;
original_filename: string;
content_type: string;
size_bytes: number;
sha256?: string;
captured_at?: string;
width?: number;
height?: number;
duration_sec?: number;
storage_key_original: string;
storage_key_thumb?: string;
created_at: string;
deleted_at?: string;
}
export interface AssetListResponse {
items: Asset[];
next_cursor?: string;
has_more: boolean;
}
export interface CreateUploadRequest {
original_filename: string;
content_type: string;
size_bytes: number;
}
export interface CreateUploadResponse {
asset_id: string;
upload_url: string;
upload_method: string;
fields?: Record<string, string>;
}
export interface DownloadUrlResponse {
url: string;
expires_in: number;
}
export interface Share {
id: string;
owner_user_id: string;
asset_id?: string;
album_id?: string;
token: string;
expires_at?: string;
created_at: string;
revoked_at?: string;
}
export interface CreateShareRequest {
asset_id?: string;
album_id?: string;
expires_in_seconds?: number;
password?: string;
}

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
},
build: {
outDir: '../static',
emptyOutDir: true,
},
})

View File

@ -0,0 +1,506 @@
# Техническое задание (ТЗ)
## Проект: облачное хранилище фото и видео (S3 + Python backend + SQLite → PostgreSQL)
**Формат фронтенда:** статический сайт (SPA) в папке `static/`, хостинг в S3
**UI:** Material UI (MUI)
**Backend:** Python (FastAPI)
**Хранилище файлов:** S3 / S3-compatible (MinIO и т.п.)
**База данных:** SQLite на старте, в будущем — PostgreSQL (миграции через Alembic)
---
## 1. Цели и принципы
### 1.1 Цель продукта
Создать удобное, быстрое и безопасное облачное хранилище для фото и видео с фокусом на:
- максимально простой и быстрый загрузчик (drag&drop, пакетные загрузки, большие файлы);
- удобную библиотеку (лента/сеткой), быстрый просмотр, поиск/фильтры;
- безопасный доступ и шаринг ссылками;
- готовность к росту: миграция SQLite → PostgreSQL без переписывания бизнес-логики.
### 1.2 Принципы реализации
- **SOLID / DRY / Clean Architecture**: разделение слоёв (API → Service → Repository → Infrastructure).
- **Докстринги обязательны** для публичных методов/классов и основных сервисов.
- **Минимум комментариев в коде**: предпочтение самодокументирующемуся коду и докстрингам.
- **Стабильный API-контракт**: OpenAPI/Swagger (FastAPI).
- **Сразу закладываем расширяемость** (теги, альбомы, шаринг, фоновые задачи).
---
## 2. Область охвата (Scope)
### 2.1 MVP (минимально жизнеспособная версия)
1) Регистрация/логин (минимум: email+пароль)
2) Загрузка фото/видео в S3 (через pre-signed URLs)
3) Библиотека медиа:
- список/сетка,
- просмотр (лайтбокс) фото,
- просмотр/проигрывание видео,
- базовые сортировки (по дате добавления, по дате съёмки).
4) Удаление (корзина/soft-delete) и восстановление.
5) Простой шаринг публичной ссылкой (срок действия).
6) Генерация превью для изображений (thumbnails).
7) Хранение метаданных (SQLite), подготовлено для PostgreSQL (ORM + миграции).
### 2.2 Версия v1 (после MVP)
- Альбомы/папки (логическая группировка).
- Теги и поиск по тегам/дате/типу/размеру.
- Извлечение EXIF (камера, дата съёмки, гео — опционально).
- Превью/постер для видео.
- Множественный выбор, пакетные операции (переместить/удалить/добавить теги).
- Пользовательские квоты и статистика хранения.
- Логи действий (аудит: загрузка/удаление/шаринг).
### 2.3 Вне рамок (Non-goals) на старте
- Полноценное видеотранскодирование в HLS/DASH.
- Распознавание лиц/умные альбомы.
- Коллаборация “семейные библиотеки” (несколько владельцев одного альбома).
- End-to-end шифрование на клиенте.
---
## 3. Роли и сценарии
### 3.1 Роли
- **User**: загружает и управляет своей медиатекой.
- **Admin** (опционально в v1): администрирование пользователей/квот и доступ ко всем облакам пользователей.
### 3.2 Ключевые user stories (MVP)
1) Как пользователь, я хочу быстро перетащить папку/файлы и загрузить их в облако.
2) Как пользователь, я хочу просматривать фото в полноэкранном режиме и листать стрелками.
3) Как пользователь, я хочу смотреть видео прямо в браузере.
4) Как пользователь, я хочу удалить файлы и при необходимости восстановить из корзины.
5) Как пользователь, я хочу поделиться ссылкой на один файл/альбом на ограниченное время.
---
## 4. UX/UI требования (фронтенд)
### 4.1 Технологическое решение фронтенда
**Рекомендуемый компромисс “JS чистый + MUI”:**
- Реализовать SPA на **React + MUI**,
- Сборка (Vite) выдаёт статические артефакты в `static/`,
- На проде в S3 лежит **только** `static/` (HTML/CSS/JS).
> Важно: Material UI (MUI) — React-библиотека. “Чистый JS без сборки” возможен через CDN + UMD, но это хуже по производительности и DX. В ТЗ закладываем правильную схему: разработка с Vite, деплой артефактов в `static/`.
### 4.2 Страницы/экраны (MVP)
1) **Login / Register**
2) **Library** (главная)
- переключатель “Grid / List”,
- фильтр по типу (photo/video),
- сортировка,
- строка поиска (в MVP можно скрыть за feature-flag).
3) **Viewer**
- фото: зум, листание, скачать,
- видео: плеер (HTML5), скачать.
4) **Upload**
- drag&drop зона,
- прогресс по файлу и общий прогресс,
- повтор при ошибке.
5) **Trash**
- список удалённых, restore/purge.
6) **Shared view** (страница по публичной ссылке)
### 4.3 Компоненты UI
- AppBar + боковое меню (Drawer)
- MediaGrid (виртуализация списка, infinite scroll)
- MediaCard (thumbnail, иконка видео, размер/дата)
- UploadDialog (dropzone + очередь + прогресс)
- ViewerModal (keyboard nav: ← → Esc)
- FiltersBar (chips/select)
### 4.4 Нефункциональные требования к UI
- **Responsive**: desktop-first, корректно на мобилках.
- **Оптимизация**:
- thumbnails грузятся лениво,
- viewer тянет оригинал только по запросу,
- infinite scroll.
- **Удобство**:
- drag&drop в любом месте Library,
- горячие клавиши в Viewer,
- понятные ошибки и “повторить загрузку”.
---
## 5. Архитектура (общая)
### 5.1 Компоненты
1) **Static SPA** (S3 hosting)
2) **Backend API** (Python)
3) **Database** (SQLite → PostgreSQL)
4) **Object storage** (S3)
5) **Background worker** (опционально сразу, обязательно для v1) — генерация превью/постеров
### 5.2 Потоки
**Upload (рекомендуемый):**
1) SPA запрашивает у backend `create_upload` (получает pre-signed URL / multipart init).
2) SPA грузит файл напрямую в S3.
3) SPA вызывает `finalize_upload` (backend фиксирует метаданные, ставит задачу на превью).
4) Backend возвращает Asset ID.
**Download/View:**
- SPA запрашивает `get_asset_download_url` → получает краткоживущую signed-ссылку на S3 (оригинал/thumbnail).
---
## 6. Backend требования
### 6.1 Стек (рекомендуется)
- **FastAPI** (ASGI)
- **Pydantic** (схемы)
- **SQLAlchemy 2.x (async)** + **Alembic** (миграции)
- **SQLite** (MVP), конфигурация диалекта для лёгкого перехода на PostgreSQL
- S3 SDK: boto3 (sync) или aioboto3 (async) / или минимальный клиент через presigned
- Auth: JWT access + refresh (HTTP-only cookies) или Bearer tokens
- Logging: structlog/loguru + стандартный logging
- Tests: pytest + httpx
### 6.2 Слоистая архитектура (Clean)
- `api/` — роуты, схемы, зависимости, auth middleware
- `services/` — бизнес-логика (upload, library, share)
- `repositories/` — доступ к данным (CRUD)
- `infra/` — S3 client, db session factory, config, background tasks
- `domain/` — модели домена/интерфейсы (по необходимости)
### 6.3 Обязательные нефункциональные требования
- Валидация входных данных (Pydantic).
- Единый формат ошибок (problem+json либо собственный стандарт).
- Rate limiting (минимально на login/share) — можно отложить до v1.
- Безопасность:
- пароли хранить только в виде хеша (argon2/bcrypt),
- все ссылки на S3 — только signed,
- CORS настроен строго,
- CSRF защита при cookie-based auth.
- Наблюдаемость:
- структурные логи,
- correlation id (request id),
- healthcheck endpoint.
---
## 7. Модель данных (SQLite → PostgreSQL)
### 7.1 Основные сущности
**users**
- `id` (UUID)
- `email` (unique)
- `password_hash`
- `created_at`, `updated_at`
- `is_active`
**assets**
- `id` (UUID)
- `user_id` (FK users)
- `type` enum: `photo|video`
- `status` enum: `uploading|ready|failed|deleted`
- `original_filename`
- `content_type`
- `size_bytes`
- `sha256` (optional, for dedup)
- `captured_at` (datetime, optional)
- `created_at`
- `deleted_at` (nullable)
- `storage_key_original` (S3 object key)
- `storage_key_thumb` (nullable)
- `width`, `height` (nullable)
- `duration_sec` (nullable)
**albums** (v1)
- `id` UUID
- `user_id`
- `title`
- `created_at`
**album_items** (v1)
- `album_id`
- `asset_id`
- `position` (int)
**tags** (v1)
- `id`, `user_id`, `name`
**asset_tags** (v1)
- `asset_id`, `tag_id`
**shares**
- `id` UUID
- `owner_user_id`
- `asset_id` (nullable) — если шаринг одного файла
- `album_id` (nullable) — если шаринг альбома
- `token` (unique, random)
- `expires_at` (nullable)
- `password_hash` (nullable)
- `created_at`
- `revoked_at` (nullable)
**auth_sessions** (опционально)
- `id`
- `user_id`
- `refresh_token_hash`
- `created_at`, `expires_at`, `revoked_at`
### 7.2 Требования к миграциям
- Все изменения БД — только через Alembic.
- Никаких raw SQL “внутри приложения”, кроме миграций.
- Схема должна работать в SQLite и PostgreSQL:
- UUID хранить как TEXT (SQLite) и UUID (PostgreSQL) через тип-переопределения,
- JSON поля избегать в MVP (или хранить TEXT).
---
## 8. Object Storage (S3)
### 8.1 Bucket и ключи
- Bucket: `MEDIA_BUCKET`
- Ключ оригинала: `u/{user_id}/o/{yyyy}/{mm}/{asset_id}{ext}`
- Ключ превью: `u/{user_id}/t/{yyyy}/{mm}/{asset_id}.jpg`
- (v1) ключ постера для видео: `u/{user_id}/p/{yyyy}/{mm}/{asset_id}.jpg`
### 8.2 Политики доступа
- Bucket **private**.
- Доступ только через pre-signed URLs с коротким TTL:
- thumbnails: 530 минут,
- originals: 110 минут (настраиваемо).
---
## 9. API (контракт)
### 9.1 Общие правила
- Версионирование: `/api/v1`
- Авторизация: `Authorization: Bearer <token>` (или cookie-based)
- Ответы: JSON
- Ошибки: единый формат `{ "error": { "code": "...", "message": "...", "details": ... } }`
### 9.2 Эндпоинты (MVP)
#### Auth
- `POST /api/v1/auth/register`
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/logout`
- `GET /api/v1/auth/me`
- (опц) `POST /api/v1/auth/refresh`
#### Assets (library)
- `GET /api/v1/assets?cursor=&limit=&type=&sort=`
- `GET /api/v1/assets/{asset_id}`
- `DELETE /api/v1/assets/{asset_id}` (soft-delete)
- `POST /api/v1/assets/{asset_id}/restore`
- `DELETE /api/v1/assets/{asset_id}/purge` (hard-delete, только из Trash)
#### Upload (direct-to-S3)
- `POST /api/v1/uploads/create`
- вход: `original_filename, content_type, size_bytes`
- выход: `asset_id, upload_method, presigned_url|presigned_post|multipart`
- `POST /api/v1/uploads/{asset_id}/finalize`
- вход: `etag` (или parts list), `sha256` (опц)
- выход: `asset`
#### URLs (signed access)
- `GET /api/v1/assets/{asset_id}/download-url?kind=original|thumb`
- `GET /api/v1/assets/{asset_id}/stream-url` (для видео, kind=original на старте)
#### Shares
- `POST /api/v1/shares` (создать ссылку)
- `GET /api/v1/shares/{token}` (получить метаданные)
- `GET /api/v1/shares/{token}/download-url?asset_id=&kind=`
- `POST /api/v1/shares/{token}/revoke`
### 9.3 Пагинация
- Cursor-based (рекомендуется): `cursor` = base64(last_created_at, last_id)
- Ответ: `{ items: [...], next_cursor: "...", has_more: true }`
---
## 10. Превью (thumbnails) и обработка
### 10.1 MVP: только изображения
- После `finalize_upload` backend ставит задачу “generate_thumbnail(asset_id)”.
**Режим исполнения (на выбор):**
1) **Inline** (допустимо в MVP): генерация превью синхронно при finalize, если файл небольшой.
2) **Background worker** (лучше): Celery/RQ + Redis, либо встроенный простейший worker.
Рекомендуемый минимум:
- Redis + RQ.
### 10.2 Технические требования к thumbnails
- Формат: JPEG
- Максимальная сторона: 512px или 1024px (настраиваемо)
- Прогрессивный JPEG (желательно)
- Сохранение в S3 по `storage_key_thumb`
- При ошибке: `assets.status = failed` или отдельное поле `thumb_status`
### 10.3 v1: видео постеры
- Генерация постера (кадр на 13 секунде) через ffmpeg.
- Хранение отдельным ключом.
---
## 11. Безопасность
### 11.1 Аутентификация и пароли
- Хэширование argon2id (предпочтительно) или bcrypt.
- Ограничение попыток логина (v1).
- Сессии/refresh токены можно отзывать.
### 11.2 Доступ к файлам
- Все доступы к S3 только через signed URLs.
- Проверка владения assetом во всех endpoints.
- Подпись ссылок короткая и на конкретный объект.
### 11.3 Публичные ссылки
- token должен быть криптостойким (>= 128 бит энтропии).
- Возможность ограничить срок действия.
- (v1) опциональный пароль на ссылку.
---
## 12. Производительность и ограничения
### 12.1 Ограничения MVP
- Максимальный размер видео: конфиг `MAX_UPLOAD_SIZE_BYTES` (например 520 ГБ).
- Для больших файлов — multipart upload (S3 multipart).
- Лимит выдачи списка: 50200 элементов, infinite scroll.
### 12.2 Кэширование
- Thumbnails: CDN-friendly, но доступ через signed URL (TTL).
- Статические файлы фронтенда: cache-control immutable для hashed assets.
---
## 13. Конфигурация (env)
### 13.1 Backend env variables (пример)
- `APP_ENV=dev|prod`
- `DATABASE_URL=sqlite+aiosqlite:///./app.db` (позже: `postgresql+asyncpg://...`)
- `S3_ENDPOINT_URL=` (если MinIO)
- `S3_REGION=`
- `S3_ACCESS_KEY_ID=`
- `S3_SECRET_ACCESS_KEY=`
- `MEDIA_BUCKET=`
- `SIGNED_URL_TTL_SECONDS=600`
- `CORS_ORIGINS=https://your-domain`
- `JWT_SECRET=`
- `JWT_ACCESS_TTL_SECONDS=900`
- `JWT_REFRESH_TTL_SECONDS=1209600`
- `MAX_UPLOAD_SIZE_BYTES=`
---
## 14. Деплой и окружения
### 14.1 Static (S3)
- Папка `static/` заливается в S3 bucket для сайта.
### 14.2 Backend
- Docker-образ, запускается как контейнер (uvicorn/gunicorn).
- Рекомендуемый `docker-compose` для dev: backend + minio + sqlite (локально) + redis.
### 14.3 База данных
- MVP: SQLite файл в volume.
- v1: PostgreSQL отдельным сервисом, переключение через `DATABASE_URL` + миграции alembic.
---
## 15. Структура репозитория (рекомендация)
```
repo/
backend/
src/
app/
api/
v1/
services/
repositories/
infra/
domain/
main.py
alembic/
tests/
pyproject.toml
Dockerfile
frontend/
src/
public/
vite.config.js
package.json
static/ # build output (deploy to S3)
docker-compose.yml
README.md
```
---
## 16. Критерии приёмки (Acceptance Criteria)
### 16.1 MVP
- Пользователь может зарегистрироваться/войти.
- Пользователь может загрузить:
- минимум 1 фото и 1 видео,
- пакет из 100+ фото,
- большой файл (в пределах лимита), без падения сервера.
- В библиотеке отображаются thumbnails для фото.
- Просмотр фото/видео работает в браузере.
- Удаление перемещает в корзину, restore возвращает.
- Public share link открывается без логина (если активен) и даёт доступ только к расшаренному объекту.
- База — SQLite, миграции работают, есть путь миграции на PostgreSQL (без изменения кода сервисов).
### 16.2 Качество кода
- Линтер/форматтер настроен (ruff + black или ruff format).
- Тесты покрывают ключевые сценарии (auth, create/finalize upload, list assets, share).
- Докстринги присутствуют в слоях services/repositories/infra.
---
## 17. План работ (укрупнённо)
### Этап 1 — фундамент (1)
- Скелет backend (FastAPI), конфиг, подключение БД, миграции.
- User + Auth (register/login/me).
- S3 интеграция: pre-signed upload + signed download.
### Этап 2 — assets (2)
- CRUD метаданных assets.
- Library list с пагинацией.
- Trash (soft-delete/restore/purge).
### Этап 3 — frontend MVP (3)
- Login/Register.
- Library grid + infinite scroll.
- Upload dialog + прогресс.
- Viewer modal.
- Trash page.
### Этап 4 — thumbnails (4)
- Генерация превью (inline или background).
- Отображение превью в Library.
### Этап 5 — shares (5)
- Создание/открытие share links.
- Shared view UI.
---
## 18. Риски и решения
- **Большие видео**: сразу закладываем multipart upload, иначе упремся в лимиты/таймауты.
- **MUI и “чистый JS”**: реальный путь — React+MUI и сборка в `static/`.
- **SQLite ограничения**: используем репозитории/ORM и Alembic, чтобы миграция на PostgreSQL была сменой `DATABASE_URL`.
- **Стоимость S3**: thumbnails уменьшают трафик; оригиналы только по запросу.
---
## 19. Доп. ПО (если понадобятся очереди/воркеры)
- **Redis** (рекомендуется)
- **RQ** (проще)
- Для видео в v1: **ffmpeg** (в контейнере воркера)
---
## 20. Примечания по лицензиям и приватности
- Пользовательские файлы и метаданные считаются приватными, но админ может всё просматривать.
- Логи не должны содержать содержимое файлов и чувствительные данные (пароли/токены).
- При шаринге — показывать минимум метаданных.