Publish #1

Merged
IgorVolochay merged 29 commits from dev into main 2026-01-10 16:02:13 +00:00
12 changed files with 1643 additions and 2 deletions
+193 -2
View File
@@ -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)
+18
View File
@@ -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
+124
View File
@@ -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="<green>{time:HH:mm:ss}</green> | <level>{level}</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())
+16
View File
@@ -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",
]
+35
View File
@@ -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)
+56
View File
@@ -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,
)
+21
View File
@@ -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
+17
View File
@@ -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
+69
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Аналог DropMeFiles</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="uploadPage">
<div id="errorPopup" class="error-popup" style="display: none;"></div>
<div class="page-content">
<h1>Аналог DropMeFiles</h1>
<div class="upload-container">
<form id="uploadForm">
<label class="dropzone" id="dropzone">
<input id="fileInput" type="file">
<p class="strong">Перетащите файл сюда или нажмите, чтобы выбрать</p>
<p>Максимальный размер файла: <span id="maxFileSize">загрузка...</span></p>
</label>
<div class="button-container">
<button type="submit" id="uploadButton" disabled>Загрузить</button>
</div>
</form>
<div id="successContent" class="success-content" style="display: none;">
<div class="file-info">
<h2 id="successFileName"></h2>
<p class="file-size" id="successFileSize"></p>
<div class="download-link-container">
<input type="text" id="downloadLinkInput" readonly class="download-link-input">
<button type="button" id="copyLinkButton" class="copy-link-button">Копировать</button>
</div>
<button type="button" id="uploadAnotherButton" class="upload-another-button">Загрузить другой файл</button>
</div>
</div>
<div class="status" id="status"></div>
<div id="uploadProgress" class="download-progress" style="display: none;">
<div class="progress-bar-container">
<div class="progress-bar" id="uploadProgressBar"></div>
</div>
<p class="progress-text" id="uploadProgressText">0%</p>
</div>
</div>
<p style="margin-top: 1.5rem; font-size: 0.875rem; color: #666; text-align: center;">
<a href="https://github.com/IgorVolochay/Drop-me-files-analog" target="_blank" rel="noopener noreferrer" style="color: #0066cc; text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle;">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub проекта
</a>
</p>
</div>
</div>
<div id="downloadPage" style="display: none;">
<div class="page-content">
<h1>Скачать файл</h1>
<div class="upload-container">
<div class="status" id="downloadStatus">Загрузка...</div>
<div id="downloadContent"></div>
<div id="downloadProgress" class="download-progress" style="display: none;">
<div class="progress-bar-container">
<div class="progress-bar" id="progressBar"></div>
</div>
<p class="progress-text" id="progressText">0%</p>
</div>
</div>
</div>
</div>
<script src="/script.js"></script>
</body>
</html>
+451
View File
@@ -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 = `
<div class="file-info">
<h2>${fileData.file_name}</h2>
<p class="file-size">Размер: ${formatBytes(parseInt(fileData.file_size))}</p>
<button type="button" id="downloadButton" class="download-button">
Загрузить
</button>
</div>
`;
// 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();
}
+570
View File
@@ -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;
}
}