diff --git a/README.md b/README.md index 324c044..8c0eba4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,193 @@ -# Drop-me-files-analog -Analog dropmefiles.com +# Drop me Files (analog) + +Аналог сервиса [dropmefiles.com](https://dropmefiles.com) — веб-приложение для временного обмена файлами с прямой загрузкой в S3-совместимое хранилище. + +Попробовать демо: [dropmefiles.viaproger.ru](https://dropmefiles.viaproger.ru/) + +## 📋 Описание + +Drop-me-files-analog позволяет пользователям: +- Загружать файлы через веб-интерфейс +- Получать короткие ссылки для скачивания (6-символьный UUID) + +Файлы загружаются напрямую в S3-совместимое хранилище (MinIO) с использованием presigned URLs, что снижает нагрузку на backend-сервер. Файлы в S3 хранилище автоматически удаляются с течением времени, и доступ к ним исчезает. + +## 🏗️ Архитектура + +Проект построен на микросервисной архитектуре и состоит из нескольких компонентов: + +**Frontend**: статическое веб-приложение (HTML, CSS, JS), предоставляющее интерфейс для загрузки и скачивания файлов. Взаимодействует с Backend через REST API. + +**Backend (FastAPI)**: API-шлюз, который генерирует presigned URLs для прямого взаимодействия клиента с S3-хранилищем, управляет метаданными файлов, валидирует запросы и логирует операции. + +**MinIO**: S3-совместимое хранилище для файлов. Доступ к файлам возможен только через presigned URLs с ограниченным временем жизни. + +**Redis**: хранилище метаданных файлов. Записи автоматически удаляются по истечении TTL. + +**Nginx**: служит веб-сервером для раздачи frontend и reverse proxy для backend. + +![Untitled](https://github.com/user-attachments/assets/2efb0a11-7a0c-486e-af03-b7da7ac225ac) + +Процесс работы: + +1. **Загрузка**: **Frontend** запрашивает токен, передавая метаданные файла. **Backend** генерирует короткий UUID и presigned POST URL для **MinIO**, сохраняет метаданные в **Redis** с TTL и возвращает URL и UUID. Frontend загружает файл напрямую в **MinIO**. + +2. **Скачивание**: По запросу с UUID **backend** проверяет **Redis**, генерирует presigned GET URL и возвращает его клиенту с метаданными. Файл скачивается напрямую из **MinIO**. + +3. **Очистка**: По истечении TTL запись удаляется из **Redis**; Файл в **MinIO** удаляется по правилам конфигурации S3 хранилища (в данном проекте, TTL MinIO равен 1 суткам). + +## 🛠️ Технологический стек + +### Backend +- **Python 3.14** +- **FastAPI** — современный асинхронный веб-фреймворк +- **Uvicorn** — ASGI сервер +- **aiobotocore** — асинхронный клиент для S3-совместимых хранилищ +- **redis** — клиент Redis +- **Pydantic** — валидация данных +- **Loguru** — логирование + +### Frontend +- **HTML**, **CSS**, **JavaScript** — базовый стек + +### Инфраструктура +- **Docker** и **Docker Compose** — для быстрой развертки сервисов +- **MinIO** — S3-совместимое хранилище +- **Redis** — кэш и хранилище метаданных +- **Nginx** — веб-сервер + +## 📦 Установка и запуск + +### Предварительные требования + +- **Docker** и **Docker Compose** — для запуска MinIO и Redis +- **Python 3.14** — для локальной разработки backend +- **UV** пакетный менеджер (рекомендуется) + +### Установка + +1. **Клонируйте репозиторий:** +```bash +git clone https://github.com/IgorVolochay/Drop-me-files-analog.git +cd Drop-me-files-analog +``` + +2. **Создайте файл `.env`** на основе примера: +```bash +cp app/.env-example app/.env +cp docker/.env-example docker/.env +``` +Затем отредактируйте `.env` и укажите необходимые значения (см. раздел "Конфигурация"). + +3. **Запустите сервисы (MinIO и Redis) через Docker Compose:** +```bash +cd docker +docker-compose up -d +``` + +Это запустит: +- MinIO на указанных портах 9000 (API) и 9001 (Веб-интерфейс) +- Автоматическую инициализацию бакета MinIO и TTL +- Redis на порту 6380 + + +4. **Установите зависимости и запустите backend:** +```bash +cd ../app +uv sync # или pip install .. +uv run main.py +``` + +Backend будет доступен на `http://localhost:8000` (или порт из `BACKEND_PORT` в `.env`). + +5. **Запустите frontend:** + +В режиме разработки используйте любой статический сервер: + +```bash + cd frontend + python -m http.server 8080 + # или + npx serve . +``` + +В production настройте Nginx для раздачи статических файлов из `frontend/` и проксирования API запросов на backend. + + +## ⚙️ Конфигурация + +### Переменные окружения + +В проекте есть пример файла конфигурации `app/.env-example`. Скопируйте его и создайте файл `.env` в корне проекта или в папке `app/` со следующими переменными: + +```python +DISABLE_DOCS=false # Отключение базового FastAPI endpoint /docs +BACKEND_PORT=8000 # Сетевой порт FastAPI Backend + +LOGS_PATH=./logs # Директория для хранения логов + +FILES_TTL=86400 # Время хранения файла (указан 1 день) +MAX_FILES_SIZE=1073741824 # Максимальный размер файла в байтах (указан 1 GB) + +# Переменные для подключения к S3 хранилищу +S3_ACCESS_KEY_ID=Some_username +S3_SECRET_ACCESS_KEY=Some_password +S3_ENDPOINT_URL=Some_url +BUCKET_NAME=drop-me-files + +# Переменные для подключения к Redis +REDIS_ADDRESS=Some_url +REDIS_PORT=6379 +REDIS_USERNAME=Some_username +REDIS_PASSWORD=Some_password +``` + + +## 🔧 Разработка + +### Структура проекта + +``` +Drop-me-files-analog/ +├── app/ # Backend приложение +│ ├── main.py # Главный файл FastAPI приложения +│ ├── s3_worker.py # Обработчик S3/MinIO +│ ├── redis_worker.py # Обработчик Redis +│ ├── schemas/ # Pydantic схемы +│ │ └── api_schemas.py +│ └── pyproject.toml # Зависимости проекта +├── frontend/ # Frontend приложение +│ ├── index.html +│ ├── script.js +│ └── style.css +├── docker/ +│ └── docker-compose.yml # Docker конфигурация для запуска MinIO и Redis +└── README.md # Этот файл +``` + +### Логирование + +Логи сохраняются в файл: `{LOGS_PATH}/dmf-logs.log` (ротация при 1 MB, сжатие zip) + + +## 🤝 Вклад + +Вклад в проект приветствуется! Пожалуйста: + +1. Создайте fork проекта +2. Создайте ветку для новой функции (`git checkout -b feature/AmazingFeature`) +3. Зафиксируйте изменения (`git commit -m 'Add some AmazingFeature'`) +4. Отправьте в ветку (`git push origin feature/AmazingFeature`) +5. Откройте Pull Request + +## 📞 Контакты + +- **GitHub проекта:** [IgorVolochay/Drop-me-files-analog](https://github.com/IgorVolochay/Drop-me-files-analog) +- **Автор:** Игорь Волочай + +Если нашли баг или уязвимость, пишите на почту (pseudo.developer.ru@gmail.com) или открывайте **Issues** на GitHub! + +## 🙏 Благодарности + +- Проект вдохновлён сервисом [dropmefiles.com](https://dropmefiles.com) +- Использует открытые технологии: [FastAPI](https://github.com/fastapi/fastapi), [MinIO](https://github.com/minio/minio), [Redis](https://github.com/redis/redis) diff --git a/app/.env-example b/app/.env-example new file mode 100644 index 0000000..9216b31 --- /dev/null +++ b/app/.env-example @@ -0,0 +1,18 @@ +DISABLE_DOCS=false +BACKEND_PORT=5000 + +LOGS_PATH=./logs + +FILES_TTL=86400 # 1 Day +MAX_FILES_SIZE=1073741824 # 1GB + +S3_ACCESS_KEY_ID=Some_username +S3_SECRET_ACCESS_KEY=Some_password +S3_ENDPOINT_URL=Some_url + +BUCKET_NAME=drop-me-files + +REDIS_ADDRESS=Some_url +REDIS_PORT=6379 +REDIS_USERNAME=Some_username +REDIS_PASSWORD=Some_password \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..385cf22 --- /dev/null +++ b/app/main.py @@ -0,0 +1,124 @@ +import dotenv +import os +import sys +import random +import string + +import asyncio +import uvicorn + +from datetime import datetime +from fastapi import FastAPI, Response, status, Request +from starlette.middleware.base import BaseHTTPMiddleware +from loguru import logger + +from schemas.api_schemas import * +from s3_worker import S3Worker +from redis_worker import RedisWorker + +dotenv.load_dotenv() + +logger.remove() +logger.add(f"{os.getenv("LOGS_PATH", "./logs")}/dmf-logs.log", + format="{time:DD-MM-YYYY HH:mm:ss.SSS}; {level}; {message}", level="DEBUG", + rotation="1 MB", compression="zip") +logger.add(sys.stdout, + level="DEBUG", + format="{time:HH:mm:ss} | {level} | {message}",) + +disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" +app: FastAPI = FastAPI(title="DropMeFiles analog", + summary="OpenAPI schema for \"DropMeFiles analog\" project!", + version="1.0", + contact={"GitHub": "https://github.com/IgorVolochay/Drop-me-files-analog"}, + docs_url=None if disable_docs else "/docs", + redoc_url=None if disable_docs else "/redoc", + openapi_url=None if disable_docs else "/openapi.json") + +s3_worker = S3Worker() +redis_worker = RedisWorker() + +max_file_size = int(os.getenv('MAX_FILES_SIZE', 1048576)) # 1 MB if None + +class RealIPMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + forwarded = request.headers.get("X-Forwarded-For", "") + if forwarded: + request.state.client_ip = forwarded.split(",")[0].strip() + else: + request.state.client_ip = request.headers.get("X-Real-IP", request.client.host) + + response = await call_next(request) + return response +app.add_middleware(RealIPMiddleware) + +@logger.catch +@app.get("/upload_token", status_code=200) +async def get_upload_token(file_name: str, file_type: str, file_size: int, response: Response, request: Request) -> BaseResponse: + user_ip = request.state.client_ip + logger.debug(f"User IP: {user_ip}; Endpoint: /upload_token; File Name: {file_name}; File Type: {file_type}; File Size: {file_size}") + if file_size > max_file_size: + response.status_code = status.HTTP_413_CONTENT_TOO_LARGE + logger.warning(f"User IP: {user_ip}; File Name: {file_name}; Exception: The file is too large") + return BaseResponse(result="The uploaded file is too large", error=True) + elif file_size <= 0: + response.status_code = status.HTTP_400_BAD_REQUEST + logger.warning(f"User IP: {user_ip}; File Name: {file_name}; Exception: The file is less than 1 byte") + return BaseResponse(result="The file you are uploading is less than 1 byte, WTF?", error=True) + + file_uuid = ''.join(random.choices(string.ascii_letters + string.digits, k=6)) + try: + post_data = await s3_worker.generate_upload_post(file_uuid, content_type=file_type) + except Exception as exception: + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + logger.error(f"User IP: {user_ip}; File Name: {file_name}; Exception: {exception}") + return BaseResponse(result="Error generating S3 access token", error=True) + + redis_worker.create_record(user_ip, file_name, file_uuid, file_type, datetime.now().isoformat(), file_size) + + logger.success(f"User IP: {user_ip}; POST data: {post_data}") + return BaseResponse(result={"data": post_data, "file_uuid": file_uuid, "comment": "Ok"}) + +@app.get("/max_file_size", status_code=200) +def get_max_file_size() -> int: + logger.debug("Max file size") + return int(os.getenv('MAX_FILES_SIZE')) + +@logger.catch +@app.get("/get_download_link/{file_uuid}", status_code=200) +async def get_file_by_uuid(file_uuid:str, response: Response, request: Request) -> BaseResponse: + user_ip = request.state.client_ip + logger.debug(f"User IP: {user_ip}; Endpoint: /get_download_link/; File UUID: {file_uuid}") + if len(file_uuid) != 6: + response.status_code = status.HTTP_404_NOT_FOUND + logger.warning(f"User IP: {user_ip}; File UUID: {file_uuid}; Exception: Invalid UUID") + return BaseResponse(result="The file UUID must be 6 characters long", error=True) + redis_data = redis_worker.get_record(file_uuid) + if not redis_data: + response.status_code = status.HTTP_400_BAD_REQUEST + logger.warning(f"User IP: {user_ip}; File UUID: {file_uuid}; Exception: File with this UUID not found") + return BaseResponse(result={"data": None, "comment": "File with this UUID not found"}, error=True) + + try: + download_url = await s3_worker.generate_download_url(file_uuid, redis_data["file_name"]) + except Exception as exception: + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + logger.error(f"User IP: {user_ip}; File UUID: {file_uuid}; Exception: {exception}") + return BaseResponse(result="Error generating S3 access token", error=True) + + logger.success(f"User IP: {user_ip}; File UUID: {file_uuid}") + return BaseResponse(result={"data": {"url": download_url, "file_name": redis_data["file_name"], "file_size": redis_data["file_size"]}, "comment": "Ok"}) + +@app.get("/health_check") +def health_check(): + logger.debug("Health check") + return True + + +async def main(): + config = uvicorn.Config("main:app", port=int(os.getenv('BACKEND_PORT', 8000)), host="0.0.0.0", log_level="critical") + server = uvicorn.Server(config) + await server.serve() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/app/pyproject.toml b/app/pyproject.toml new file mode 100644 index 0000000..8013285 --- /dev/null +++ b/app/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "drop-me-files-analog" +version = "0.1.0" +description = "" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "aiobotocore>=2.26.0", + "dotenv>=0.9.9", + "fastapi>=0.121.1", + "loguru>=0.7.3", + "pydantic>=2.12.5", + "python-multipart>=0.0.20", + "redis>=7.1.0", + "uvicorn>=0.38.0", +] diff --git a/app/redis_worker.py b/app/redis_worker.py new file mode 100644 index 0000000..d74a615 --- /dev/null +++ b/app/redis_worker.py @@ -0,0 +1,35 @@ +import os + +import redis +from dotenv import load_dotenv + + +class RedisWorker: + def __init__(self): + load_dotenv() + self.client = redis.Redis( + host=os.getenv('REDIS_ADDRESS'), + port=int(os.getenv('REDIS_PORT')), + username=os.getenv('REDIS_USERNAME'), + password=os.getenv('REDIS_PASSWORD'), + decode_responses=True, + socket_timeout=10, + socket_connect_timeout=10, + retry_on_timeout=True, + max_connections=50 + ) + self.files_ttl = int(os.getenv('FILES_TTL')) + + def create_record(self, user_ip: str, file_name: str, file_uuid: str, file_type: str, add_date:str, file_size:int): + self.client.hset(file_uuid, mapping={ + "file_name": file_name, + "file_size": file_size, + "user_ip": user_ip, + "file_type": file_type, + "add_date": add_date + }) + self.client.expire(file_uuid, self.files_ttl) + + + def get_record(self, file_uuid:str): + return self.client.hgetall(file_uuid) \ No newline at end of file diff --git a/app/s3_worker.py b/app/s3_worker.py new file mode 100644 index 0000000..79024b8 --- /dev/null +++ b/app/s3_worker.py @@ -0,0 +1,56 @@ +import os + +from contextlib import asynccontextmanager + +from dotenv import load_dotenv +from aiobotocore.session import get_session + + +class S3Worker: + def __init__(self): + load_dotenv() + + self._s3_config = { + "aws_access_key_id": os.getenv('S3_ACCESS_KEY_ID'), + "aws_secret_access_key": os.getenv('S3_SECRET_ACCESS_KEY'), + "endpoint_url": os.getenv('S3_ENDPOINT_URL') + } + + self._s3_session = get_session() + + self.bucket = os.getenv('BUCKET_NAME') + self.max_file_size = os.getenv('MAX_FILES_SIZE') + + @asynccontextmanager + async def get_client(self): + async with self._s3_session.create_client("s3", **self._s3_config) as client: + yield client + + async def generate_upload_post(self, key: str, content_type: str, expires_in: int = 300) -> dict: + async with self.get_client() as client: + return await client.generate_presigned_post( + Bucket=self.bucket, + Key=key, + Fields={ + "Content-Type": content_type, + "acl": "private", + }, + Conditions=[ + ["content-length-range", 0, self.max_file_size], + {"acl": "private"}, + ], + ExpiresIn=expires_in, + ) + + async def generate_download_url(self, key: str, filename: str, expires_in: int = 300) -> str: + async with self.get_client() as client: + return await client.generate_presigned_url("get_object", + Params={ + "Bucket": self.bucket, + "Key": key, + "ResponseContentDisposition": ( + f'attachment; filename="{filename}"' + ), + }, + ExpiresIn=expires_in, + ) \ No newline at end of file diff --git a/app/schemas/api_schemas.py b/app/schemas/api_schemas.py new file mode 100644 index 0000000..8d0dd1b --- /dev/null +++ b/app/schemas/api_schemas.py @@ -0,0 +1,21 @@ +import typing + +from pydantic import BaseModel, Field, HttpUrl, PositiveInt + + +class BaseResponse(BaseModel): + result: typing.Any + error: bool = False + +class UploadFields(BaseModel): + content_type: str = Field(alias="Content-Type") + acl: str + key: str + aws_access_key_id: str = Field(alias="AWSAccessKeyId") + policy: str + signature: str + + +class UploadToken(BaseModel): + url: HttpUrl + fields: UploadFields \ No newline at end of file diff --git a/docker/.env-example b/docker/.env-example new file mode 100644 index 0000000..64c2b4d --- /dev/null +++ b/docker/.env-example @@ -0,0 +1,17 @@ +S3_ACCESS_KEY_ID=your_access_key +S3_SECRET_ACCESS_KEY=your_secret_key + +BUCKET_NAME=your_bucket_name + +MINIO_DATA_PATH=/path/to/data + +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 + + +REDIS_PORT=6380 +REDIS_DATA_PATH=/path/to/data + +REDIS_PASSWORD=my_redis_password +REDIS_USER=my_user +REDIS_USER_PASSWORD=my_user_password \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..58cf880 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,69 @@ +version: "3.9" + +services: + minio: + image: minio/minio:RELEASE.2025-09-07T16-13-09Z + container_name: dmf-minio-server + restart: always + ports: + - "${MINIO_API_PORT}:9000" + - "${MINIO_CONSOLE_PORT}:9001" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID} + MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY} + volumes: + - "${MINIO_DATA_PATH}:/data" + command: server /data --console-address ":9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/ready"] + interval: 5s + timeout: 3s + retries: 5 + + minio-init: + image: minio/mc:RELEASE.2025-09-07T16-13-09Z + depends_on: + minio: + condition: service_healthy + environment: + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} + BUCKET_NAME: ${BUCKET_NAME} + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY} && + mc mb -p local/${BUCKET_NAME} || true && + mc ilm rule add --expire-days 1 local/${BUCKET_NAME} + " + + + redis: + image: redis:latest + container_name: dmf-redis + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD} + - REDIS_USER=${REDIS_USER} + - REDIS_USER_PASSWORD=${REDIS_USER_PASSWORD} + ports: + - "${REDIS_PORT}:6379" + volumes: + - "${REDIS_DATA_PATH}:/data" + command: > + sh -c ' + mkdir -p /usr/local/etc/redis && + echo "bind 0.0.0.0" > /usr/local/etc/redis/redis.conf && + echo "requirepass $REDIS_PASSWORD" >> /usr/local/etc/redis/redis.conf && + echo "appendonly yes" >> /usr/local/etc/redis/redis.conf && + echo "appendfsync everysec" >> /usr/local/etc/redis/redis.conf && + echo "user default on nopass ~* +@all" > /usr/local/etc/redis/users.acl && + echo "user $REDIS_USER on >$REDIS_USER_PASSWORD ~* +@all" >> /usr/local/etc/redis/users.acl && + redis-server /usr/local/etc/redis/redis.conf --aclfile /usr/local/etc/redis/users.acl + ' + healthcheck: + test: [ "CMD", "redis-cli", "-a", "$REDIS_PASSWORD", "ping" ] + interval: 30s + timeout: 10s + retries: 5 + restart: always + tty: true + stdin_open: true diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..aa97202 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,73 @@ + + + + + + Аналог DropMeFiles + + + +
+ +
+

Аналог DropMeFiles

+
+
+ +
+ +
+
+ +
+ +
+

+ + + + + GitHub проекта + +

+
+
+ + + + + + diff --git a/frontend/script.js b/frontend/script.js new file mode 100644 index 0000000..4408ffa --- /dev/null +++ b/frontend/script.js @@ -0,0 +1,451 @@ +// Utility function to format bytes to human-readable format +function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +} + +// Check if we're on the download page +const path = window.location.pathname; +// Match /get/ followed by any alphanumeric characters (not just exactly 6) +const downloadMatch = path.match(/^\/get\/([a-zA-Z0-9]+)$/); + +if (downloadMatch) { + // Show download page + document.getElementById('uploadPage').style.display = 'none'; + document.getElementById('downloadPage').style.display = 'block'; + + const fileUuid = downloadMatch[1]; + handleDownloadPage(fileUuid); +} else { + // Show upload page + document.getElementById('uploadPage').style.display = 'block'; + document.getElementById('downloadPage').style.display = 'none'; + initUploadPage(); +} + +// Download page handler +async function handleDownloadPage(fileUuid) { + const downloadStatus = document.getElementById('downloadStatus'); + const downloadContent = document.getElementById('downloadContent'); + + // Check UUID length first (must be exactly 6 characters) + if (fileUuid.length !== 6) { + downloadStatus.textContent = 'Мы не смогли ничего найти.\nПерепроверьте введенный адрес.'; + downloadStatus.className = 'status error'; + downloadStatus.style.display = 'block'; + downloadContent.innerHTML = ''; + return; + } + + // Show loading state + downloadStatus.textContent = 'Загрузка...'; + downloadStatus.className = 'status'; + downloadStatus.style.display = 'block'; + downloadContent.innerHTML = ''; + + try { + const response = await fetch(`/api/get_download_link/${fileUuid}`); + + // Check for network/connection errors + if (!response.ok && (response.status === 0 || response.status >= 500)) { + downloadStatus.textContent = 'Сервис временно недоступен,\nприносим наши извинения.'; + downloadStatus.className = 'status error'; + downloadStatus.style.display = 'block'; + downloadContent.innerHTML = ''; + return; + } + + const data = await response.json(); + + // Check for errors (400, 404, or error flag in response) + if (data.error || !data.result || !data.result.data || response.status === 400 || response.status === 404) { + downloadStatus.textContent = 'Мы не смогли ничего найти.\nПерепроверьте введенный адрес.'; + downloadStatus.className = 'status error'; + downloadStatus.style.display = 'block'; + downloadContent.innerHTML = ''; + return; + } + + const fileData = data.result.data; + const fileSize = parseInt(fileData.file_size); + + // Create download button handler with progress tracking + const handleDownload = async () => { + const downloadButton = document.getElementById('downloadButton'); + const downloadProgress = document.getElementById('downloadProgress'); + const progressBar = document.getElementById('progressBar'); + const progressText = document.getElementById('progressText'); + + // For small files (< 10MB), use direct download (browser's native download) + // For larger files, show progress bar + if (fileSize < 10 * 1024 * 1024) { + // Small file - use direct download with browser's native progress + const link = document.createElement('a'); + link.href = fileData.url; + link.download = fileData.file_name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + return; + } + + // Large file - download with progress tracking + try { + downloadButton.disabled = true; + downloadProgress.style.display = 'block'; + progressBar.style.width = '0%'; + progressText.textContent = '0%'; + + const response = await fetch(fileData.url); + if (!response.ok) { + throw new Error('Ошибка при загрузке файла'); + } + + const contentLength = response.headers.get('content-length'); + const total = contentLength ? parseInt(contentLength, 10) : fileSize; + let loaded = 0; + + const reader = response.body.getReader(); + const chunks = []; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + loaded += value.length; + + const percent = Math.round((loaded / total) * 100); + progressBar.style.width = percent + '%'; + progressText.textContent = `${percent}% (${formatBytes(loaded)} / ${formatBytes(total)})`; + } + + // Combine chunks into blob + const blob = new Blob(chunks); + const blobUrl = window.URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = blobUrl; + link.download = fileData.file_name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up + window.URL.revokeObjectURL(blobUrl); + downloadProgress.style.display = 'none'; + downloadButton.disabled = false; + progressText.textContent = 'Загрузка завершена!'; + + setTimeout(() => { + progressText.textContent = ''; + }, 2000); + } catch (error) { + downloadProgress.style.display = 'none'; + downloadButton.disabled = false; + // Fallback: try direct navigation + window.location.href = fileData.url; + } + }; + + downloadContent.innerHTML = ` +
+

${fileData.file_name}

+

Размер: ${formatBytes(parseInt(fileData.file_size))}

+ +
+ `; + + // Attach click handler to the download button + const downloadButton = document.getElementById('downloadButton'); + downloadButton.addEventListener('click', handleDownload); + + downloadStatus.style.display = 'none'; + } catch (error) { + // Network error or other connection issues + downloadStatus.textContent = 'Сервис временно недоступен,\nприносим наши извинения.'; + downloadStatus.className = 'status error'; + downloadStatus.style.display = 'block'; + } +} + +// Show error popup +function showErrorPopup(message) { + const popup = document.getElementById('errorPopup'); + popup.textContent = message; + popup.style.display = 'block'; + + // Auto-hide after 5 seconds + setTimeout(() => { + popup.style.display = 'none'; + }, 5000); +} + +// Upload page initialization +function initUploadPage() { + const fileInput = document.getElementById('fileInput'); + const dropzone = document.getElementById('dropzone'); + const uploadButton = document.getElementById('uploadButton'); + const uploadForm = document.getElementById('uploadForm'); + const statusDiv = document.getElementById('status'); + const maxFileSizeSpan = document.getElementById('maxFileSize'); + const successContent = document.getElementById('successContent'); + const uploadFormContainer = uploadForm.parentElement; + + let maxFileSizeBytes = 0; + + // Fetch max file size on page load + async function loadMaxFileSize() { + try { + const response = await fetch('/api/max_file_size'); + if (!response.ok) { + throw new Error(`Ошибка сервера: ${response.status}`); + } + maxFileSizeBytes = await response.json(); + maxFileSizeSpan.textContent = formatBytes(maxFileSizeBytes); + + // Set max file size attribute + fileInput.setAttribute('data-max-size', maxFileSizeBytes); + } catch (error) { + maxFileSizeSpan.textContent = 'ошибка загрузки'; + maxFileSizeSpan.style.color = '#dc2626'; + console.error('Error loading max file size:', error); + } + } + + loadMaxFileSize(); + + const updateButtonState = () => { + if (!fileInput.files || fileInput.files.length === 0) { + uploadButton.disabled = true; + return; + } + + const file = fileInput.files[0]; + if (file.size > maxFileSizeBytes && maxFileSizeBytes > 0) { + const errorMsg = `Файл слишком большой. Максимальный размер: ${formatBytes(maxFileSizeBytes)}`; + showErrorPopup(errorMsg); + uploadButton.disabled = true; + return; + } + + uploadButton.disabled = false; + setStatus('', ''); + }; + + const setStatus = (message, type = '') => { + statusDiv.textContent = message; + statusDiv.className = type ? `status ${type}` : 'status'; + // Hide status div when empty to avoid unnecessary spacing + if (!message || message.trim() === '') { + statusDiv.style.display = 'none'; + } else { + statusDiv.style.display = 'block'; + } + }; + + const showSuccessState = (fileName, fileSize, downloadUrl) => { + // Hide the form + uploadForm.style.display = 'none'; + statusDiv.style.display = 'none'; + + // Show success content + document.getElementById('successFileName').textContent = fileName; + document.getElementById('successFileSize').textContent = `Размер: ${formatBytes(fileSize)}`; + document.getElementById('downloadLinkInput').value = downloadUrl; + successContent.style.display = 'block'; + + // Setup copy button + const copyButton = document.getElementById('copyLinkButton'); + copyButton.onclick = () => { + const input = document.getElementById('downloadLinkInput'); + input.select(); + input.setSelectionRange(0, 99999); // For mobile devices + document.execCommand('copy'); + + const originalText = copyButton.textContent; + copyButton.textContent = 'Скопировано!'; + copyButton.classList.add('copied'); + + setTimeout(() => { + copyButton.textContent = originalText; + copyButton.classList.remove('copied'); + }, 2000); + }; + + // Setup "upload another" button + const uploadAnotherButton = document.getElementById('uploadAnotherButton'); + uploadAnotherButton.onclick = () => { + // Reset form + uploadForm.style.display = 'block'; + successContent.style.display = 'none'; + fileInput.value = ''; + updateButtonState(); + }; + }; + + dropzone.addEventListener('dragover', (event) => { + event.preventDefault(); + dropzone.classList.add('dragover'); + }); + + dropzone.addEventListener('dragleave', () => { + dropzone.classList.remove('dragover'); + }); + + dropzone.addEventListener('drop', (event) => { + event.preventDefault(); + dropzone.classList.remove('dragover'); + + if (event.dataTransfer?.files?.length) { + // Only take the first file + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(event.dataTransfer.files[0]); + fileInput.files = dataTransfer.files; + updateButtonState(); + } + }); + + fileInput.addEventListener('change', () => { + updateButtonState(); + }); + + uploadForm.addEventListener('submit', async (event) => { + event.preventDefault(); + + if (!fileInput.files || fileInput.files.length === 0) { + showErrorPopup('Выберите файл для загрузки.'); + return; + } + + const file = fileInput.files[0]; + + if (maxFileSizeBytes > 0 && file.size > maxFileSizeBytes) { + showErrorPopup(`Файл слишком большой. Максимальный размер: ${formatBytes(maxFileSizeBytes)}`); + return; + } + + uploadButton.disabled = true; + dropzone.classList.remove('dragover'); + setStatus('Получение токена загрузки...', ''); + + try { + // Step 1: Get upload token + const tokenUrl = `/api/upload_token?file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type)}&file_size=${file.size}`; + const tokenResponse = await fetch(tokenUrl); + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json().catch(() => ({})); + // Extract error message without status codes + let errorMessage = errorData.result || 'Ошибка при загрузке файла'; + if (typeof errorMessage === 'object' && errorMessage.comment) { + errorMessage = errorMessage.comment; + } + if (typeof errorMessage === 'string' && errorMessage.includes('too large')) { + errorMessage = 'Файл слишком большой'; + } + throw new Error(errorMessage); + } + + const tokenData = await tokenResponse.json(); + + if (tokenData.error || !tokenData.result || !tokenData.result.data) { + let errorMessage = tokenData.result?.comment || 'Ошибка при получении токена загрузки'; + throw new Error(errorMessage); + } + + const uploadData = tokenData.result.data; + const fileUuid = tokenData.result.file_uuid; + uploadData.fields["key"] = fileUuid; + delete uploadData.fields["Content-Type"]; + + // Hide status, show progress bar + statusDiv.style.display = 'none'; + const uploadProgress = document.getElementById('uploadProgress'); + const uploadProgressBar = document.getElementById('uploadProgressBar'); + const uploadProgressText = document.getElementById('uploadProgressText'); + uploadProgress.style.display = 'block'; + uploadProgressBar.style.width = '0%'; + uploadProgressText.textContent = '0%'; + + // Step 2: Upload to S3 using XMLHttpRequest for progress tracking + await new Promise((resolve, reject) => { + const formData = new FormData(); + Object.keys(uploadData.fields).forEach(key => { + formData.append(key, uploadData.fields[key]); + }); + // File must be appended last + formData.append('file', file); + + const xhr = new XMLHttpRequest(); + + // Track upload progress + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const percent = Math.round((event.loaded / event.total) * 100); + uploadProgressBar.style.width = percent + '%'; + uploadProgressText.textContent = `${percent}% (${formatBytes(event.loaded)} / ${formatBytes(event.total)})`; + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } else { + reject(new Error(`Ошибка загрузки: ${xhr.status}`)); + } + }); + + xhr.addEventListener('error', () => { + reject(new Error('Ошибка при загрузке файла на сервер')); + }); + + xhr.addEventListener('abort', () => { + reject(new Error('Загрузка прервана')); + }); + + xhr.open('POST', uploadData.url); + xhr.send(formData); + }); + + // Hide progress bar after successful upload + uploadProgress.style.display = 'none'; + + // Success! + const downloadUrl = `${window.location.origin}/get/${fileUuid}`; + showSuccessState(file.name, file.size, downloadUrl); + fileInput.value = ''; + + } catch (error) { + // Extract clean error message without status codes + let errorMessage = error.message; + if (errorMessage.includes('status') || errorMessage.match(/\d{3}/)) { + if (errorMessage.includes('too large') || errorMessage.includes('413')) { + errorMessage = 'Файл слишком большой'; + } else if (errorMessage.includes('500')) { + errorMessage = 'Ошибка сервера. Попробуйте позже'; + } else { + errorMessage = 'Не удалось загрузить файл'; + } + } + showErrorPopup(errorMessage); + setStatus('', ''); + // Hide progress bar on error + const uploadProgress = document.getElementById('uploadProgress'); + if (uploadProgress) { + uploadProgress.style.display = 'none'; + } + } finally { + uploadButton.disabled = false; + } + }); + + updateButtonState(); +} diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..4ba8173 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,570 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: Arial, sans-serif; + background: radial-gradient(circle at top, #f7f7fb, #eff1f8); + color: #111827; + padding: 24px; +} + +@media (max-width: 768px) { + body { + padding: 16px; + justify-content: center; + } +} + +.page-content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 640px; +} + +h1 { + font-size: clamp(32px, 4vw, 48px); + text-align: center; + margin: 0 0 32px 0; + width: 100%; +} + +@media (max-width: 768px) { + h1 { + font-size: clamp(24px, 6vw, 32px); + margin: 0 0 24px 0; + } +} + +.upload-container { + width: min(640px, 100%); + background: #ffffffdd; + border-radius: 20px; + box-shadow: 0 20px 45px -20px rgba(15, 23, 42, 0.65); + padding: 32px; + display: flex; + flex-direction: column; + gap: 24px; + backdrop-filter: blur(8px); +} + +@media (max-width: 768px) { + .upload-container { + padding: 20px; + gap: 16px; + border-radius: 16px; + } +} + +.dropzone { + border: 2px dashed #6366f1; + border-radius: 16px; + padding: 48px 24px; + text-align: center; + transition: all 0.2s ease; + background: rgba(99, 102, 241, 0.04); + cursor: pointer; + position: relative; + display: block; + overflow: hidden; +} + +@media (max-width: 768px) { + .dropzone { + padding: 32px 16px; + border-radius: 12px; + } +} + +.dropzone:focus, +.dropzone:focus-within { + outline: none; + border: 2px dashed #6366f1; +} + +.dropzone.dragover { + background: rgba(99, 102, 241, 0.12); + transform: translateY(-2px); + box-shadow: 0 14px 28px -24px rgba(99, 102, 241, 0.65); +} + +.dropzone input[type="file"] { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + outline: none !important; + border: none !important; + box-shadow: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + font-size: 0 !important; + line-height: 0 !important; + z-index: 1; +} + +.dropzone input[type="file"]:focus, +.dropzone input[type="file"]:active, +.dropzone input[type="file"]:hover, +.dropzone input[type="file"]:focus-visible, +.dropzone input[type="file"]::-webkit-file-upload-button { + outline: none !important; + border: none !important; + box-shadow: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; +} + +.dropzone input[type="file"]::-webkit-file-upload-button { + display: none !important; + visibility: hidden !important; + opacity: 0 !important; +} + +.dropzone p { + margin: 8px 0; + color: #4b5563; + font-size: 16px; +} + +.dropzone p.strong { + font-weight: 600; + color: #1f2937; + font-size: 18px; +} + +button { + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: white; + border: none; + border-radius: 12px; + padding: 14px 20px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +@media (max-width: 768px) { + button { + padding: 16px 24px; + font-size: 16px; + min-height: 48px; + width: 100%; + } +} + +button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 16px 32px -24px rgba(99, 102, 241, 0.8); +} + +button:disabled { + background: #cbd5f5; + cursor: not-allowed; + box-shadow: none; +} + +.button-container { + display: flex; + justify-content: center; + width: 100%; + margin-top: 24px; +} + +.status { + font-size: 15px; + color: #374151; + line-height: 1.5; + word-break: break-all; + min-height: 0; +} + +.status:empty { + display: none; +} + +.status.error { + color: #dc2626; + font-size: 18px; + font-weight: 500; + white-space: pre-line; + line-height: 1.6; +} + +.status.success { + color: #166534; +} + +.file-info { + text-align: center; + padding: 24px; +} + +.file-info h2 { + margin: 0 0 16px 0; + font-size: 24px; + color: #1f2937; +} + +.file-size { + margin: 0 0 24px 0; + color: #6b7280; + font-size: 16px; +} + +.download-button { + display: inline-block; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: white; + border: none; + border-radius: 12px; + padding: 14px 32px; + font-size: 16px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + font-family: inherit; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +@media (max-width: 768px) { + .download-button { + padding: 16px 24px; + width: 100%; + min-height: 48px; + } +} + +.download-button:hover { + transform: translateY(-1px); + box-shadow: 0 16px 32px -24px rgba(99, 102, 241, 0.8); +} + +.download-button:active { + transform: translateY(0); +} + +/* Error popup */ +.error-popup { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: #fee2e2; + border: 2px solid #dc2626; + border-radius: 12px; + padding: 16px 24px; + color: #991b1b; + font-weight: 600; + font-size: 15px; + z-index: 1000; + box-shadow: 0 10px 25px -5px rgba(220, 38, 38, 0.3); + animation: slideDown 0.3s ease-out; + max-width: 90%; + text-align: center; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateX(-50%) translateY(-20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@media (max-width: 768px) { + .error-popup { + top: 10px; + left: 10px; + right: 10px; + transform: none; + max-width: none; + padding: 12px 16px; + font-size: 14px; + } + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .status { + font-size: 14px; + } + + .status.error { + font-size: 16px; + } + + .file-info h2 { + font-size: 20px; + } + + .file-size { + font-size: 14px; + } + + .dropzone p { + font-size: 14px; + } + + .dropzone p.strong { + font-size: 16px; + } +} + +/* Success content */ +.success-content { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.download-link-container { + display: flex; + gap: 12px; + margin-top: 24px; + align-items: center; + justify-content: center; + flex-wrap: wrap; +} + +.download-link-input { + flex: 1; + min-width: 200px; + padding: 12px 16px; + border: 2px solid #e5e7eb; + border-radius: 8px; + font-size: 14px; + font-family: monospace; + background: #f9fafb; + color: #111827; + cursor: text; +} + +@media (max-width: 768px) { + .download-link-input { + width: 100%; + min-width: 0; + font-size: 12px; + padding: 14px; + } +} + +.download-link-input:focus { + outline: none; + border-color: #6366f1; + background: #ffffff; +} + +.copy-link-button { + padding: 12px 24px; + background: linear-gradient(135deg, #10b981, #059669); + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; + white-space: nowrap; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +@media (max-width: 768px) { + .copy-link-button { + width: 100%; + padding: 14px 24px; + min-height: 48px; + } +} + +.copy-link-button:hover { + transform: translateY(-1px); + box-shadow: 0 8px 16px -4px rgba(16, 185, 129, 0.4); +} + +.copy-link-button:active { + transform: translateY(0); +} + +.copy-link-button.copied { + background: linear-gradient(135deg, #6366f1, #8b5cf6); +} + +.upload-another-button { + margin-top: 24px; + padding: 12px 24px; + background: transparent; + color: #6366f1; + border: 2px solid #6366f1; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +@media (max-width: 768px) { + .upload-another-button { + width: 100%; + padding: 14px 24px; + min-height: 48px; + } +} + +.upload-another-button:hover { + background: #6366f1; + color: white; + transform: translateY(-1px); + box-shadow: 0 8px 16px -4px rgba(99, 102, 241, 0.4); +} + +/* Download progress */ +.download-progress { + margin-top: 24px; + text-align: center; +} + +.progress-bar-container { + width: 100%; + height: 8px; + background: #e5e7eb; + border-radius: 4px; + overflow: hidden; + margin-bottom: 12px; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, #6366f1, #8b5cf6); + border-radius: 4px; + transition: width 0.3s ease; + width: 0%; +} + +.progress-text { + font-size: 14px; + color: #6b7280; + margin: 0; + font-weight: 500; +} + +@media (max-width: 768px) { + .status { + font-size: 14px; + } + + .status.error { + font-size: 16px; + } + + .file-info h2 { + font-size: 20px; + } + + .file-size { + font-size: 14px; + } + + .dropzone p { + font-size: 14px; + } + + .dropzone p.strong { + font-size: 16px; + } + + .error-popup { + top: 10px; + left: 10px; + right: 10px; + transform: none; + max-width: none; + padding: 12px 16px; + font-size: 14px; + } + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .progress-text { + font-size: 12px; + } + + .button-container { + margin-top: 16px; + } +} + +@media (max-width: 480px) { + body { + padding: 12px; + } + + h1 { + font-size: 22px; + margin: 0 0 20px 0; + } + + .upload-container { + padding: 16px; + gap: 12px; + } + + .dropzone { + padding: 24px 12px; + } + + .file-info { + padding: 16px; + } +}