Publish #1
@@ -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.
|
||||
|
||||

|
||||
|
||||
Процесс работы:
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user