From 1c6ab276c86bff92835be0bc9fc24463c2cc3364 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Fri, 14 Nov 2025 16:27:04 +0300 Subject: [PATCH 01/29] File uploader --- backend/main.py | 49 ++++++++++++++++++ backend/pyproject.toml | 12 +++++ frontend/index.html | 25 ++++++++++ frontend/script.js | 77 +++++++++++++++++++++++++++++ frontend/style.css | 110 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 273 insertions(+) create mode 100644 backend/main.py create mode 100644 backend/pyproject.toml create mode 100644 frontend/index.html create mode 100644 frontend/script.js create mode 100644 frontend/style.css diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..d1b5f22 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI, File, UploadFile +from fastapi.middleware.cors import CORSMiddleware + +from typing import Annotated + +import dotenv +import os + +import asyncio +import uvicorn + + +dotenv.load_dotenv() +disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" +app: FastAPI = FastAPI(title="This OR That", + summary="OpenAPI schema for \"This OR That\" project!", + version="0.1", + contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"}, + 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") + + +origins = ["*"] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.get("/") +def root(): + return "Hello!" + +@app.post("/upload_file") +@app.post("/upload_file") +async def create_file(file: Annotated[bytes, File()]): + return {"file_size": len(file)} + +async def main(): + config = uvicorn.Config("main:app", port=5000, log_level="debug") + server = uvicorn.Server(config) + await server.serve() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..9f799ca --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "drop-me-files-analog" +version = "0.1.0" +description = "" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "dotenv>=0.9.9", + "fastapi>=0.121.1", + "python-multipart>=0.0.20", + "uvicorn>=0.38.0", +] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6786f81 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,25 @@ + + + + + + Аналог DropMeFiles + + + +

Аналог DropMeFiles

+
+
+ + +
+
+
+ + + + diff --git a/frontend/script.js b/frontend/script.js new file mode 100644 index 0000000..6f6a61f --- /dev/null +++ b/frontend/script.js @@ -0,0 +1,77 @@ +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 updateButtonState = () => { + uploadButton.disabled = !fileInput.files || fileInput.files.length === 0; +}; + +const setStatus = (message, type = '') => { + statusDiv.textContent = message; + statusDiv.className = type ? `status ${type}` : 'status'; +}; + +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) { + fileInput.files = event.dataTransfer.files; + updateButtonState(); + } +}); + +fileInput.addEventListener('change', () => { + updateButtonState(); +}); + +uploadForm.addEventListener('submit', async (event) => { + event.preventDefault(); + + if (!fileInput.files || fileInput.files.length === 0) { + setStatus('Выберите файлов для загрузки.', 'error'); + return; + } + + const formData = new FormData(); + Array.from(fileInput.files).forEach((file) => { + formData.append('files', file); + }); + + uploadButton.disabled = true; + dropzone.classList.remove('dragover'); + setStatus('Загрузка...', ''); + + try { + const response = await fetch('http://192.168.0.132:5000/upload_file', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error(`Ошибка сервера: ${response.status}`); + } + + const data = await response.text(); + setStatus(`Путь к файлу: ${data}`, 'success'); + fileInput.value = ''; + } catch (error) { + setStatus(`Не удалось загрузить файлы: ${error.message}`, 'error'); + } finally { + uploadButton.disabled = false; + } +}); + +updateButtonState(); + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..3036f50 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,110 @@ +* { + 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; +} + +h1 { + font-size: clamp(32px, 4vw, 48px); + margin-bottom: 32px; + text-align: center; +} + +.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); +} + +.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; +} + +.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; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.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; +} + +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; +} + +.status { + min-height: 24px; + font-size: 15px; + color: #374151; + line-height: 1.5; + word-break: break-all; +} + +.status.error { + color: #dc2626; +} + +.status.success { + color: #166534; +} + From b6fded6526982702354f1d30ace492e28036cc40 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Fri, 21 Nov 2025 14:37:19 +0300 Subject: [PATCH 02/29] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20405=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {frontend => app/frontend}/index.html | 0 {frontend => app/frontend}/script.js | 2 +- {frontend => app/frontend}/style.css | 0 app/main.py | 33 ++++++++++++++++++ {backend => app}/pyproject.toml | 0 backend/main.py | 49 --------------------------- 6 files changed, 34 insertions(+), 50 deletions(-) rename {frontend => app/frontend}/index.html (100%) rename {frontend => app/frontend}/script.js (96%) rename {frontend => app/frontend}/style.css (100%) create mode 100644 app/main.py rename {backend => app}/pyproject.toml (100%) delete mode 100644 backend/main.py diff --git a/frontend/index.html b/app/frontend/index.html similarity index 100% rename from frontend/index.html rename to app/frontend/index.html diff --git a/frontend/script.js b/app/frontend/script.js similarity index 96% rename from frontend/script.js rename to app/frontend/script.js index 6f6a61f..fbb5290 100644 --- a/frontend/script.js +++ b/app/frontend/script.js @@ -54,7 +54,7 @@ uploadForm.addEventListener('submit', async (event) => { setStatus('Загрузка...', ''); try { - const response = await fetch('http://192.168.0.132:5000/upload_file', { + const response = await fetch('/upload_file', { method: 'POST', body: formData }); diff --git a/frontend/style.css b/app/frontend/style.css similarity index 100% rename from frontend/style.css rename to app/frontend/style.css diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3aec6bf --- /dev/null +++ b/app/main.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI, File, UploadFile +from fastapi.staticfiles import StaticFiles + +import dotenv +import os + +import asyncio +import uvicorn + +dotenv.load_dotenv() +disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" +app: FastAPI = FastAPI(title="DropMeFiles analog") +# app: FastAPI = FastAPI(title="DropMeFiles analog", +# summary="OpenAPI schema for \"DropMeFiles analog\" project!", +# version="0.1", +# 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") + +# app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend") + +@app.post("/upload_file") +async def create_upload_file(file: UploadFile = File(...)): + return {"filename": file.filename, "content_type": file.content_type} + +async def main(): + config = uvicorn.Config("main:app", port=5000, host="0.0.0.0", log_level="debug") + server = uvicorn.Server(config) + await server.serve() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/backend/pyproject.toml b/app/pyproject.toml similarity index 100% rename from backend/pyproject.toml rename to app/pyproject.toml diff --git a/backend/main.py b/backend/main.py deleted file mode 100644 index d1b5f22..0000000 --- a/backend/main.py +++ /dev/null @@ -1,49 +0,0 @@ -from fastapi import FastAPI, File, UploadFile -from fastapi.middleware.cors import CORSMiddleware - -from typing import Annotated - -import dotenv -import os - -import asyncio -import uvicorn - - -dotenv.load_dotenv() -disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" -app: FastAPI = FastAPI(title="This OR That", - summary="OpenAPI schema for \"This OR That\" project!", - version="0.1", - contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"}, - 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") - - -origins = ["*"] - -app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -@app.get("/") -def root(): - return "Hello!" - -@app.post("/upload_file") -@app.post("/upload_file") -async def create_file(file: Annotated[bytes, File()]): - return {"file_size": len(file)} - -async def main(): - config = uvicorn.Config("main:app", port=5000, log_level="debug") - server = uvicorn.Server(config) - await server.serve() - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file From c85e2e550c40bbaeb5a37f6b2f3ef8fc04f49d5d Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 23 Dec 2025 13:40:28 +0300 Subject: [PATCH 03/29] Test minio S3 --- app/main.py | 16 +++++++++++++--- app/minio_test.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 app/minio_test.py diff --git a/app/main.py b/app/main.py index 3aec6bf..b4b767d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,9 @@ from fastapi import FastAPI, File, UploadFile -from fastapi.staticfiles import StaticFiles import dotenv import os +import random +import string import asyncio import uvicorn @@ -18,11 +19,20 @@ app: FastAPI = FastAPI(title="DropMeFiles analog") # redoc_url=None if disable_docs else "/redoc", # openapi_url=None if disable_docs else "/openapi.json") -# app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend") +data = dict() @app.post("/upload_file") async def create_upload_file(file: UploadFile = File(...)): - return {"filename": file.filename, "content_type": file.content_type} + random_string = ''.join(random.choices(string.ascii_letters + string.digits, k=6)) + data[random_string] = file.filename + return {"filename": file.filename, "content_type": file.content_type, "id": random_string} + +@app.get("/{item_id}") +async def read_item(item_id: str): + if item_id in data.keys(): + return {"return": data[item_id]} + else: + return {"return": "NO DATA!"} async def main(): config = uvicorn.Config("main:app", port=5000, host="0.0.0.0", log_level="debug") diff --git a/app/minio_test.py b/app/minio_test.py new file mode 100644 index 0000000..2292202 --- /dev/null +++ b/app/minio_test.py @@ -0,0 +1,44 @@ +import asyncio +from aiobotocore.session import get_session + + +AWS_ACCESS_KEY_ID = "some_username" +AWS_SECRET_ACCESS_KEY = "some_password" +ENDPOINT_URL = "" + + +async def main(): + session = get_session() + + # Создаем S3 клиент + async with session.create_client( + "s3", + region_name="us-east-1", + aws_secret_access_key=AWS_SECRET_ACCESS_KEY, + aws_access_key_id=AWS_ACCESS_KEY_ID, + endpoint_url=ENDPOINT_URL, # важно для MinIO + ) as s3: + + bucket = "mybucket" + key = "example.txt" + + # 1. Создаем bucket + print("Создаю bucket…") + await s3.create_bucket(Bucket=bucket) + + # 2. Загружаем файл (put_object) + print("Загружаю файл…") + await s3.put_object( + Bucket=bucket, + Key=key, + Body=b"Hello from aiobotocore + MinIO!" + ) + + # 3. Скачиваем файл + print("Скачиваю файл…") + obj = await s3.get_object(Bucket=bucket, Key=key) + data = await obj["Body"].read() + print("Содержимое:", data.decode()) + + +asyncio.run(main()) From 1a58387666f1d67e0e79ecf83e837106b27e7a6d Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 29 Dec 2025 12:57:20 +0300 Subject: [PATCH 04/29] Create S3 Worker --- app/pyproject.toml | 2 ++ app/s3_worker.py | 88 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 app/s3_worker.py diff --git a/app/pyproject.toml b/app/pyproject.toml index 9f799ca..0251a7e 100644 --- a/app/pyproject.toml +++ b/app/pyproject.toml @@ -5,8 +5,10 @@ description = "" readme = "README.md" requires-python = ">=3.11" dependencies = [ + "aiobotocore>=2.26.0", "dotenv>=0.9.9", "fastapi>=0.121.1", + "minio>=7.2.20", "python-multipart>=0.0.20", "uvicorn>=0.38.0", ] diff --git a/app/s3_worker.py b/app/s3_worker.py new file mode 100644 index 0000000..99d10cc --- /dev/null +++ b/app/s3_worker.py @@ -0,0 +1,88 @@ +import os + +import asyncio + +from dotenv import load_dotenv +from aiobotocore.session import get_session + + +class S3Worker: + def __init__(self): + load_dotenv() + + self._S3_ACCESS_KEY_ID = os.getenv('S3_ACCESS_KEY_ID') + self._S3_SECRET_ACCESS_KEY = os.getenv('S3_SECRET_ACCESS_KEY') + self._S3_ENDPOINT_URL = os.getenv('S3_ENDPOINT_URL') + + self._s3_session = get_session() + self._s3_client = None + + self.bucket = os.getenv('BUCKET_NAME') + + async def __aenter__(self): + self._client = await self._s3_session.create_client( + "s3", + region_name="us-east-1", + aws_access_key_id=self._S3_ACCESS_KEY_ID, + aws_secret_access_key=self._S3_SECRET_ACCESS_KEY, + endpoint_url=self._S3_ENDPOINT_URL, + ).__aenter__() + return self + + async def __aexit__(self, exc_type, exc, tb): + await self._client.__aexit__(exc_type, exc, tb) + + + async def upload_file(self, key: str, data: bytes): + await self._client.put_object( + Bucket=self.bucket, + Key=key, + Body=data, + ) + + async def generate_upload_url(self, key: str, content_type: str, expires_in: int = 300) -> str: + return await self._client.generate_presigned_url("put_object", + Params={ + "Bucket": self.bucket, + "Key": key, + "ContentType": content_type, + }, + ExpiresIn=expires_in, + ) + + async def download_file(self, key: str): + return await self._client.get_object( + Bucket=self.bucket, + Key=key, + ) + + async def generate_download_url(self, key: str, filename: str, expires_in: int = 300) -> str: + return await self._client.generate_presigned_url("get_object", + Params={ + "Bucket": self.bucket, + "Key": key, + "ResponseContentDisposition": ( + f'attachment; filename="{filename}"' + ), + }, + ExpiresIn=expires_in, + ) + + +async def test_run(): + async with S3Worker() as worker: + await worker.upload_file("test.txt", b"hello") + file = await worker.download_file("test.txt") + file_text = await file["Body"].read() + print(file_text) + + url = await worker.generate_download_url("test.txt", "hello.txt") + print(url) + url = await worker.generate_upload_url("some.jpg", content_type="image/jpeg") + print(url) + url = await worker.generate_download_url("some.jpg", "image.jpg") + print(url) + + +if __name__ == "__main__": + asyncio.run(test_run()) \ No newline at end of file From e4dfbc7ab5134ce0ea82c7fcbb02feabe9b6938c Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 30 Dec 2025 00:01:17 +0300 Subject: [PATCH 05/29] Add docker compose with MinIO and Redis --- app/minio_test.py | 44 --------------------------------------- docker/.env-example | 0 docker/docker-compose.yml | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 44 deletions(-) delete mode 100644 app/minio_test.py create mode 100644 docker/.env-example create mode 100644 docker/docker-compose.yml diff --git a/app/minio_test.py b/app/minio_test.py deleted file mode 100644 index 2292202..0000000 --- a/app/minio_test.py +++ /dev/null @@ -1,44 +0,0 @@ -import asyncio -from aiobotocore.session import get_session - - -AWS_ACCESS_KEY_ID = "some_username" -AWS_SECRET_ACCESS_KEY = "some_password" -ENDPOINT_URL = "" - - -async def main(): - session = get_session() - - # Создаем S3 клиент - async with session.create_client( - "s3", - region_name="us-east-1", - aws_secret_access_key=AWS_SECRET_ACCESS_KEY, - aws_access_key_id=AWS_ACCESS_KEY_ID, - endpoint_url=ENDPOINT_URL, # важно для MinIO - ) as s3: - - bucket = "mybucket" - key = "example.txt" - - # 1. Создаем bucket - print("Создаю bucket…") - await s3.create_bucket(Bucket=bucket) - - # 2. Загружаем файл (put_object) - print("Загружаю файл…") - await s3.put_object( - Bucket=bucket, - Key=key, - Body=b"Hello from aiobotocore + MinIO!" - ) - - # 3. Скачиваем файл - print("Скачиваю файл…") - obj = await s3.get_object(Bucket=bucket, Key=key) - data = await obj["Body"].read() - print("Содержимое:", data.decode()) - - -asyncio.run(main()) diff --git a/docker/.env-example b/docker/.env-example new file mode 100644 index 0000000..e69de29 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..9ffcbca --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,36 @@ +version: "3.9" + +services: + minio: + image: minio/minio:latest + container_name: minio-server + ports: + - "5356:9000" + - "5357:9001" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID} + MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY} + volumes: + - /home/volochay/coldstorage/minio:/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:latest + 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} + " From 45488974b95f4dcb2c4acfd46b0a49aeba889468 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 30 Dec 2025 00:03:52 +0300 Subject: [PATCH 06/29] Add docker compose with MinIO and Redis --- docker/.env-example | 17 ++++++++++++++++ docker/docker-compose.yml | 41 +++++++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/docker/.env-example b/docker/.env-example index e69de29..64c2b4d 100644 --- a/docker/.env-example +++ b/docker/.env-example @@ -0,0 +1,17 @@ +S3_ACCESS_KEY_ID=your_access_key +S3_SECRET_ACCESS_KEY=your_secret_key + +BUCKET_NAME=your_bucket_name + +MINIO_DATA_PATH=/path/to/data + +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 + + +REDIS_PORT=6380 +REDIS_DATA_PATH=/path/to/data + +REDIS_PASSWORD=my_redis_password +REDIS_USER=my_user +REDIS_USER_PASSWORD=my_user_password \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9ffcbca..893ae04 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -3,15 +3,16 @@ version: "3.9" services: minio: image: minio/minio:latest - container_name: minio-server + container_name: dmf-minio-server + restart: always ports: - - "5356:9000" - - "5357:9001" + - "${MINIO_API_PORT}:9000" + - "${MINIO_CONSOLE_PORT}:9001" environment: MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY} volumes: - - /home/volochay/coldstorage/minio:/data + - "${MINIO_DATA_PATH}:/data" command: server /data --console-address ":9001" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/ready"] @@ -34,3 +35,35 @@ services: 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: unless-stopped + tty: true + stdin_open: true From 2f749180ee3beb25ce55703629b70f4f9d2090da Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 30 Dec 2025 22:02:08 +0300 Subject: [PATCH 07/29] Rework s3 upload system --- app/s3_worker.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/app/s3_worker.py b/app/s3_worker.py index 99d10cc..f691b65 100644 --- a/app/s3_worker.py +++ b/app/s3_worker.py @@ -2,6 +2,7 @@ import os import asyncio +from anyio import Condition from dotenv import load_dotenv from aiobotocore.session import get_session @@ -18,6 +19,7 @@ class S3Worker: self._s3_client = None self.bucket = os.getenv('BUCKET_NAME') + self.max_file_size = os.getenv('MAX_FILES_SIZE') async def __aenter__(self): self._client = await self._s3_session.create_client( @@ -40,13 +42,18 @@ class S3Worker: Body=data, ) - async def generate_upload_url(self, key: str, content_type: str, expires_in: int = 300) -> str: - return await self._client.generate_presigned_url("put_object", - Params={ - "Bucket": self.bucket, - "Key": key, - "ContentType": content_type, + async def generate_upload_post(self, key: str, content_type: str, expires_in: int = 300) -> dict: + return await self._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, ) @@ -78,7 +85,7 @@ async def test_run(): url = await worker.generate_download_url("test.txt", "hello.txt") print(url) - url = await worker.generate_upload_url("some.jpg", content_type="image/jpeg") + url = await worker.generate_upload_post("some.jpg", content_type="image/jpeg") print(url) url = await worker.generate_download_url("some.jpg", "image.jpg") print(url) From 325a9d9ff1743d57e0c8ceaed49d6f645fd68ef0 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 30 Dec 2025 23:03:49 +0300 Subject: [PATCH 08/29] Create Redis worker --- app/redis_worker.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 app/redis_worker.py diff --git a/app/redis_worker.py b/app/redis_worker.py new file mode 100644 index 0000000..4a2812b --- /dev/null +++ b/app/redis_worker.py @@ -0,0 +1,36 @@ +import os +from datetime import datetime + +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 + ) + 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): + self.client.hset(file_uuid, mapping={ + "file_name": file_name, + "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) + + +if __name__ == "__main__": + worker = RedisWorker() + worker.create_record("127.0.0.1", "some.jpg", "QWERTY", "image/jpeg", datetime.now().isoformat()) + print(worker.get_record("QWERTY")) From ecd3d79f2bbd9ee72a834fa1222d688985dc5271 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sat, 3 Jan 2026 17:37:37 +0300 Subject: [PATCH 09/29] Create /upload_token endpoint --- app/main.py | 43 +++++++++++++++++++++++++------------- app/pyproject.toml | 2 ++ app/redis_worker.py | 4 +++- app/s3_worker.py | 5 ++--- app/schemas/api_schemas.py | 21 +++++++++++++++++++ 5 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 app/schemas/api_schemas.py diff --git a/app/main.py b/app/main.py index b4b767d..6692cfd 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,3 @@ -from fastapi import FastAPI, File, UploadFile - import dotenv import os import random @@ -8,6 +6,13 @@ import string import asyncio import uvicorn +from datetime import datetime +from fastapi import FastAPI, Response, status, Request + +from schemas.api_schemas import * +from s3_worker import S3Worker +from redis_worker import RedisWorker + dotenv.load_dotenv() disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" app: FastAPI = FastAPI(title="DropMeFiles analog") @@ -18,21 +23,31 @@ app: FastAPI = FastAPI(title="DropMeFiles 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() -data = dict() -@app.post("/upload_file") -async def create_upload_file(file: UploadFile = File(...)): - random_string = ''.join(random.choices(string.ascii_letters + string.digits, k=6)) - data[random_string] = file.filename - return {"filename": file.filename, "content_type": file.content_type, "id": random_string} +@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) -> UploadToken | BaseResponse: + if file_size > int(os.getenv('MAX_FILES_SIZE')): + response.status_code = status.HTTP_413_CONTENT_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 + return BaseResponse(result="The file you are uploading is less than 1 byte, WTF?", error=True) -@app.get("/{item_id}") -async def read_item(item_id: str): - if item_id in data.keys(): - return {"return": data[item_id]} - else: - return {"return": "NO DATA!"} + user_ip = request.client.host + file_uuid = ''.join(random.choices(string.ascii_letters + string.digits, k=6)) + async with s3_worker as worker: + try: + post_data = await worker.generate_upload_post(file_name, content_type=file_type) + except Exception as exception: + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + return BaseResponse(result="Error generating S3 access token. Error: " + str(exception), error=True) + + redis_worker.create_record(user_ip, file_name, file_uuid, file_type, datetime.now().isoformat(), file_size) + print(post_data) + return UploadToken.model_validate(post_data) async def main(): config = uvicorn.Config("main:app", port=5000, host="0.0.0.0", log_level="debug") diff --git a/app/pyproject.toml b/app/pyproject.toml index 0251a7e..63551fe 100644 --- a/app/pyproject.toml +++ b/app/pyproject.toml @@ -9,6 +9,8 @@ dependencies = [ "dotenv>=0.9.9", "fastapi>=0.121.1", "minio>=7.2.20", + "pydantic>=2.12.5", "python-multipart>=0.0.20", + "redis>=7.1.0", "uvicorn>=0.38.0", ] diff --git a/app/redis_worker.py b/app/redis_worker.py index 4a2812b..16edcb6 100644 --- a/app/redis_worker.py +++ b/app/redis_worker.py @@ -17,15 +17,17 @@ class RedisWorker: ) 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): + 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) diff --git a/app/s3_worker.py b/app/s3_worker.py index f691b65..1903c97 100644 --- a/app/s3_worker.py +++ b/app/s3_worker.py @@ -2,7 +2,6 @@ import os import asyncio -from anyio import Condition from dotenv import load_dotenv from aiobotocore.session import get_session @@ -75,9 +74,9 @@ class S3Worker: ExpiresIn=expires_in, ) - +s3_worker = S3Worker() async def test_run(): - async with S3Worker() as worker: + async with s3_worker as worker: await worker.upload_file("test.txt", b"hello") file = await worker.download_file("test.txt") file_text = await file["Body"].read() diff --git a/app/schemas/api_schemas.py b/app/schemas/api_schemas.py new file mode 100644 index 0000000..8d0dd1b --- /dev/null +++ b/app/schemas/api_schemas.py @@ -0,0 +1,21 @@ +import typing + +from pydantic import BaseModel, Field, HttpUrl, PositiveInt + + +class BaseResponse(BaseModel): + result: typing.Any + error: bool = False + +class UploadFields(BaseModel): + content_type: str = Field(alias="Content-Type") + acl: str + key: str + aws_access_key_id: str = Field(alias="AWSAccessKeyId") + policy: str + signature: str + + +class UploadToken(BaseModel): + url: HttpUrl + fields: UploadFields \ No newline at end of file From aa5d4f439ef455811fb60cae393e533fe62128ac Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 5 Jan 2026 16:12:43 +0300 Subject: [PATCH 10/29] Create /get_download_link endpoint --- app/main.py | 29 +++++++++++++++++++++++++++-- app/redis_worker.py | 2 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 6692cfd..92ee87b 100644 --- a/app/main.py +++ b/app/main.py @@ -28,7 +28,7 @@ redis_worker = RedisWorker() @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) -> UploadToken | BaseResponse: +async def get_upload_token(file_name: str, file_type: str, file_size: int, response: Response, request: Request) -> BaseResponse: if file_size > int(os.getenv('MAX_FILES_SIZE')): response.status_code = status.HTTP_413_CONTENT_TOO_LARGE return BaseResponse(result="The uploaded file is too large", error=True) @@ -47,7 +47,32 @@ async def get_upload_token(file_name: str, file_type: str, file_size: int, respo redis_worker.create_record(user_ip, file_name, file_uuid, file_type, datetime.now().isoformat(), file_size) print(post_data) - return UploadToken.model_validate(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: + return int(os.getenv('MAX_FILES_SIZE')) + + +@app.get("/get_download_link/{file_uuid}", status_code=200) +async def get_file_by_uuid(file_uuid:str, response: Response, request: Request) -> BaseResponse: + if len(file_uuid) != 6: + response.status_code = status.HTTP_404_NOT_FOUND + 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 + return BaseResponse(result={"data": None, "comment": "File with this UUID not found"}, error=True) + + async with s3_worker as worker: + try: + download_url = await worker.generate_download_url(file_uuid, redis_data["file_name"]) + except Exception as exception: + response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + return BaseResponse(result="Error generating S3 access token. Error: " + str(exception), error=True) + + return BaseResponse(result={"data": {"url": download_url, "file_name": redis_data["file_name"], "file_size": redis_data["file_size"]}, "comment": "Ok"}) + async def main(): config = uvicorn.Config("main:app", port=5000, host="0.0.0.0", log_level="debug") diff --git a/app/redis_worker.py b/app/redis_worker.py index 16edcb6..1d672ee 100644 --- a/app/redis_worker.py +++ b/app/redis_worker.py @@ -34,5 +34,5 @@ class RedisWorker: if __name__ == "__main__": worker = RedisWorker() - worker.create_record("127.0.0.1", "some.jpg", "QWERTY", "image/jpeg", datetime.now().isoformat()) + worker.create_record("127.0.0.1", "some.jpg", "QWERTY", "image/jpeg", datetime.now().isoformat(), file_size=1024) print(worker.get_record("QWERTY")) From ac5025d4fc6fa02627b93b309af0285e95032290 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 5 Jan 2026 16:13:26 +0300 Subject: [PATCH 11/29] Rewrite frontend --- app/frontend/index.html | 33 +++-- app/frontend/script.js | 275 ++++++++++++++++++++++++++++++---------- app/frontend/style.css | 36 ++++++ 3 files changed, 264 insertions(+), 80 deletions(-) diff --git a/app/frontend/index.html b/app/frontend/index.html index 6786f81..ff2caec 100644 --- a/app/frontend/index.html +++ b/app/frontend/index.html @@ -7,17 +7,28 @@ -

Аналог DropMeFiles

-
-
- - -
-
+
+

Аналог DropMeFiles

+
+
+ + +
+
+
+
+ + diff --git a/app/frontend/script.js b/app/frontend/script.js index fbb5290..fe3b91d 100644 --- a/app/frontend/script.js +++ b/app/frontend/script.js @@ -1,77 +1,214 @@ -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'); +// 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]; +} -const updateButtonState = () => { - uploadButton.disabled = !fileInput.files || fileInput.files.length === 0; -}; +// Check if we're on the download page +const path = window.location.pathname; +const downloadMatch = path.match(/^\/get\/([a-zA-Z0-9]{6})$/); -const setStatus = (message, type = '') => { - statusDiv.textContent = message; - statusDiv.className = type ? `status ${type}` : 'status'; -}; - -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) { - fileInput.files = event.dataTransfer.files; - updateButtonState(); - } -}); - -fileInput.addEventListener('change', () => { - updateButtonState(); -}); - -uploadForm.addEventListener('submit', async (event) => { - event.preventDefault(); - - if (!fileInput.files || fileInput.files.length === 0) { - setStatus('Выберите файлов для загрузки.', 'error'); - return; - } - - const formData = new FormData(); - Array.from(fileInput.files).forEach((file) => { - formData.append('files', file); - }); - - uploadButton.disabled = true; - dropzone.classList.remove('dragover'); - setStatus('Загрузка...', ''); +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'); + try { - const response = await fetch('/upload_file', { - method: 'POST', - body: formData - }); - - if (!response.ok) { - throw new Error(`Ошибка сервера: ${response.status}`); + const response = await fetch(`/get_download_link/${fileUuid}`); + const data = await response.json(); + + if (data.error || !data.result || !data.result.data) { + downloadStatus.textContent = data.result?.comment || 'Ошибка при получении файла'; + downloadStatus.className = 'status error'; + return; } - - const data = await response.text(); - setStatus(`Путь к файлу: ${data}`, 'success'); - fileInput.value = ''; + + const fileData = data.result.data; + downloadContent.innerHTML = ` +
+

${fileData.file_name}

+

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

+ + Скачать файл + +
+ `; + downloadStatus.style.display = 'none'; } catch (error) { - setStatus(`Не удалось загрузить файлы: ${error.message}`, 'error'); - } finally { - uploadButton.disabled = false; + downloadStatus.textContent = `Ошибка: ${error.message}`; + downloadStatus.className = 'status error'; } -}); - -updateButtonState(); +} +// 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'); + + let maxFileSizeBytes = 0; + + // Fetch max file size on page load + async function loadMaxFileSize() { + try { + const response = await fetch('/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) { + setStatus(`Файл слишком большой. Максимальный размер: ${formatBytes(maxFileSizeBytes)}`, 'error'); + uploadButton.disabled = true; + return; + } + + uploadButton.disabled = false; + setStatus('', ''); + }; + + const setStatus = (message, type = '') => { + statusDiv.textContent = message; + statusDiv.className = type ? `status ${type}` : 'status'; + }; + + 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) { + setStatus('Выберите файл для загрузки.', 'error'); + return; + } + + const file = fileInput.files[0]; + + if (maxFileSizeBytes > 0 && file.size > maxFileSizeBytes) { + setStatus(`Файл слишком большой. Максимальный размер: ${formatBytes(maxFileSizeBytes)}`, 'error'); + return; + } + + uploadButton.disabled = true; + dropzone.classList.remove('dragover'); + setStatus('Получение токена загрузки...', ''); + + try { + // Step 1: Get upload token + const tokenUrl = `/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(() => ({})); + throw new Error(errorData.result?.comment || `Ошибка сервера: ${tokenResponse.status}`); + } + + const tokenData = await tokenResponse.json(); + + if (tokenData.error || !tokenData.result || !tokenData.result.data) { + throw new Error(tokenData.result?.comment || 'Ошибка при получении токена загрузки'); + } + + const uploadData = tokenData.result.data; + const fileUuid = tokenData.result.file_uuid; + + setStatus('Загрузка файла на сервер...', ''); + + // Step 2: Upload to S3 using form-data + // Add all fields from the API response (order matters for S3, file should be last) + 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 uploadResponse = await fetch(uploadData.url, { + method: 'POST', + body: formData + }); + + if (!uploadResponse.ok) { + throw new Error(`Ошибка загрузки на S3: ${uploadResponse.status}`); + } + + // Success! + const downloadUrl = `${window.location.origin}/get/${fileUuid}`; + setStatus(`Файл успешно загружен! UUID: ${fileUuid}. Ссылка для скачивания: ${downloadUrl}`, 'success'); + fileInput.value = ''; + updateButtonState(); + + } catch (error) { + setStatus(`Не удалось загрузить файл: ${error.message}`, 'error'); + } finally { + uploadButton.disabled = false; + } + }); + + updateButtonState(); +} diff --git a/app/frontend/style.css b/app/frontend/style.css index 3036f50..31371fd 100644 --- a/app/frontend/style.css +++ b/app/frontend/style.css @@ -108,3 +108,39 @@ button:disabled { 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; +} + +.download-button:hover { + transform: translateY(-1px); + box-shadow: 0 16px 32px -24px rgba(99, 102, 241, 0.8); +} + From 96808e073bb0708704b117cd044f269891898d8e Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 5 Jan 2026 18:31:25 +0300 Subject: [PATCH 12/29] Move frontend dir --- {app/frontend => frontend}/index.html | 0 {app/frontend => frontend}/script.js | 0 {app/frontend => frontend}/style.css | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename {app/frontend => frontend}/index.html (100%) rename {app/frontend => frontend}/script.js (100%) rename {app/frontend => frontend}/style.css (100%) diff --git a/app/frontend/index.html b/frontend/index.html similarity index 100% rename from app/frontend/index.html rename to frontend/index.html diff --git a/app/frontend/script.js b/frontend/script.js similarity index 100% rename from app/frontend/script.js rename to frontend/script.js diff --git a/app/frontend/style.css b/frontend/style.css similarity index 100% rename from app/frontend/style.css rename to frontend/style.css From a9e2da3cccb9e77437c3ee474cbbacd9177d3ad8 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 5 Jan 2026 19:11:49 +0300 Subject: [PATCH 13/29] Docker without compose --- docker/run-minio.sh | 32 ++++++++++++++++++++++++++++++++ docker/run-redis.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 docker/run-minio.sh create mode 100644 docker/run-redis.sh diff --git a/docker/run-minio.sh b/docker/run-minio.sh new file mode 100644 index 0000000..826723a --- /dev/null +++ b/docker/run-minio.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -e + +# --- MinIO server --- +docker rm -f dmf-minio-server 2>/dev/null || true + +docker run -d \ + --name dmf-minio-server \ + --restart always \ + -p "${MINIO_API_PORT}:9000" \ + -p "${MINIO_CONSOLE_PORT}:9001" \ + -e MINIO_ROOT_USER="${S3_ACCESS_KEY_ID}" \ + -e MINIO_ROOT_PASSWORD="${S3_SECRET_ACCESS_KEY}" \ + -v "${MINIO_DATA_PATH}:/data" \ + minio/minio:latest \ + server /data --console-address ":9001" + +sleep 5 + +# --- MinIO init (mc) --- +docker run --rm \ + --name dmf-minio-init \ + --link dmf-minio-server:minio \ + -e S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID}" \ + -e S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY}" \ + -e BUCKET_NAME="${BUCKET_NAME}" \ + minio/mc:latest \ + /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} + " diff --git a/docker/run-redis.sh b/docker/run-redis.sh new file mode 100644 index 0000000..45eacab --- /dev/null +++ b/docker/run-redis.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -e + +docker rm -f dmf-redis 2>/dev/null || true + +docker run -d \ + --name dmf-redis \ + --restart always \ + -p "${REDIS_PORT}:6379" \ + -e REDIS_PASSWORD="${REDIS_PASSWORD}" \ + -e REDIS_USER="${REDIS_USER}" \ + -e REDIS_USER_PASSWORD="${REDIS_USER_PASSWORD}" \ + -v "${REDIS_DATA_PATH}:/data" \ + -t -i \ + redis:latest \ + 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 + ' From c11fb3d9b72eaead3df5a46b005e669d7a8b024c Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Mon, 5 Jan 2026 22:40:32 +0300 Subject: [PATCH 14/29] Cringe --- docker/run-minio.sh | 32 -------------------------------- docker/run-redis.sh | 28 ---------------------------- 2 files changed, 60 deletions(-) delete mode 100644 docker/run-minio.sh delete mode 100644 docker/run-redis.sh diff --git a/docker/run-minio.sh b/docker/run-minio.sh deleted file mode 100644 index 826723a..0000000 --- a/docker/run-minio.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -set -e - -# --- MinIO server --- -docker rm -f dmf-minio-server 2>/dev/null || true - -docker run -d \ - --name dmf-minio-server \ - --restart always \ - -p "${MINIO_API_PORT}:9000" \ - -p "${MINIO_CONSOLE_PORT}:9001" \ - -e MINIO_ROOT_USER="${S3_ACCESS_KEY_ID}" \ - -e MINIO_ROOT_PASSWORD="${S3_SECRET_ACCESS_KEY}" \ - -v "${MINIO_DATA_PATH}:/data" \ - minio/minio:latest \ - server /data --console-address ":9001" - -sleep 5 - -# --- MinIO init (mc) --- -docker run --rm \ - --name dmf-minio-init \ - --link dmf-minio-server:minio \ - -e S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID}" \ - -e S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY}" \ - -e BUCKET_NAME="${BUCKET_NAME}" \ - minio/mc:latest \ - /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} - " diff --git a/docker/run-redis.sh b/docker/run-redis.sh deleted file mode 100644 index 45eacab..0000000 --- a/docker/run-redis.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -set -e - -docker rm -f dmf-redis 2>/dev/null || true - -docker run -d \ - --name dmf-redis \ - --restart always \ - -p "${REDIS_PORT}:6379" \ - -e REDIS_PASSWORD="${REDIS_PASSWORD}" \ - -e REDIS_USER="${REDIS_USER}" \ - -e REDIS_USER_PASSWORD="${REDIS_USER_PASSWORD}" \ - -v "${REDIS_DATA_PATH}:/data" \ - -t -i \ - redis:latest \ - 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 - ' From 6920ff381c74a8907bcbc1992863d0a3437a27b6 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 6 Jan 2026 17:27:59 +0300 Subject: [PATCH 15/29] Bugfix --- app/main.py | 24 +++++----- app/s3_worker.py | 107 ++++++++++++++++++--------------------------- frontend/script.js | 8 ++-- 3 files changed, 59 insertions(+), 80 deletions(-) diff --git a/app/main.py b/app/main.py index 92ee87b..497aab9 100644 --- a/app/main.py +++ b/app/main.py @@ -38,12 +38,11 @@ async def get_upload_token(file_name: str, file_type: str, file_size: int, respo user_ip = request.client.host file_uuid = ''.join(random.choices(string.ascii_letters + string.digits, k=6)) - async with s3_worker as worker: - try: - post_data = await worker.generate_upload_post(file_name, content_type=file_type) - except Exception as exception: - response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - return BaseResponse(result="Error generating S3 access token. Error: " + str(exception), error=True) + 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 + return BaseResponse(result="Error generating S3 access token. Error: " + str(exception), error=True) redis_worker.create_record(user_ip, file_name, file_uuid, file_type, datetime.now().isoformat(), file_size) print(post_data) @@ -64,18 +63,17 @@ async def get_file_by_uuid(file_uuid:str, response: Response, request: Request) response.status_code = status.HTTP_400_BAD_REQUEST return BaseResponse(result={"data": None, "comment": "File with this UUID not found"}, error=True) - async with s3_worker as worker: - try: - download_url = await worker.generate_download_url(file_uuid, redis_data["file_name"]) - except Exception as exception: - response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - return BaseResponse(result="Error generating S3 access token. Error: " + str(exception), 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 + return BaseResponse(result="Error generating S3 access token. Error: " + str(exception), error=True) return BaseResponse(result={"data": {"url": download_url, "file_name": redis_data["file_name"], "file_size": redis_data["file_size"]}, "comment": "Ok"}) async def main(): - config = uvicorn.Config("main:app", port=5000, host="0.0.0.0", log_level="debug") + config = uvicorn.Config("main:app", port=int(os.getenv('BACKEND_PORT')), host="0.0.0.0", log_level="debug") server = uvicorn.Server(config) await server.serve() diff --git a/app/s3_worker.py b/app/s3_worker.py index 1903c97..43cf67a 100644 --- a/app/s3_worker.py +++ b/app/s3_worker.py @@ -1,6 +1,7 @@ import os import asyncio +from contextlib import asynccontextmanager from dotenv import load_dotenv from aiobotocore.session import get_session @@ -10,84 +11,62 @@ class S3Worker: def __init__(self): load_dotenv() - self._S3_ACCESS_KEY_ID = os.getenv('S3_ACCESS_KEY_ID') - self._S3_SECRET_ACCESS_KEY = os.getenv('S3_SECRET_ACCESS_KEY') - self._S3_ENDPOINT_URL = os.getenv('S3_ENDPOINT_URL') + 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._s3_client = None self.bucket = os.getenv('BUCKET_NAME') self.max_file_size = os.getenv('MAX_FILES_SIZE') - async def __aenter__(self): - self._client = await self._s3_session.create_client( - "s3", - region_name="us-east-1", - aws_access_key_id=self._S3_ACCESS_KEY_ID, - aws_secret_access_key=self._S3_SECRET_ACCESS_KEY, - endpoint_url=self._S3_ENDPOINT_URL, - ).__aenter__() - return self - - async def __aexit__(self, exc_type, exc, tb): - await self._client.__aexit__(exc_type, exc, tb) - - - async def upload_file(self, key: str, data: bytes): - await self._client.put_object( - Bucket=self.bucket, - Key=key, - Body=data, - ) + @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: - return await self._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 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 download_file(self, key: str): - return await self._client.get_object( - Bucket=self.bucket, - Key=key, - ) async def generate_download_url(self, key: str, filename: str, expires_in: int = 300) -> str: - return await self._client.generate_presigned_url("get_object", - Params={ - "Bucket": self.bucket, - "Key": key, - "ResponseContentDisposition": ( - f'attachment; filename="{filename}"' - ), - }, - ExpiresIn=expires_in, - ) + 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, + ) + -s3_worker = S3Worker() async def test_run(): - async with s3_worker as worker: - await worker.upload_file("test.txt", b"hello") - file = await worker.download_file("test.txt") - file_text = await file["Body"].read() - print(file_text) + worker = S3Worker() - url = await worker.generate_download_url("test.txt", "hello.txt") - print(url) - url = await worker.generate_upload_post("some.jpg", content_type="image/jpeg") - print(url) - url = await worker.generate_download_url("some.jpg", "image.jpg") - print(url) + url = await worker.generate_download_url("test.txt", "hello.txt") + print(url) + url = await worker.generate_upload_post("some.jpg", content_type="image/jpeg") + print(url) + url = await worker.generate_download_url("some.jpg", "image.jpg") + print(url) if __name__ == "__main__": diff --git a/frontend/script.js b/frontend/script.js index fe3b91d..96d2da5 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -33,7 +33,7 @@ async function handleDownloadPage(fileUuid) { const downloadContent = document.getElementById('downloadContent'); try { - const response = await fetch(`/get_download_link/${fileUuid}`); + const response = await fetch(`/api/get_download_link/${fileUuid}`); const data = await response.json(); if (data.error || !data.result || !data.result.data) { @@ -73,7 +73,7 @@ function initUploadPage() { // Fetch max file size on page load async function loadMaxFileSize() { try { - const response = await fetch('/max_file_size'); + const response = await fetch('/api/max_file_size'); if (!response.ok) { throw new Error(`Ошибка сервера: ${response.status}`); } @@ -160,7 +160,7 @@ function initUploadPage() { try { // Step 1: Get upload token - const tokenUrl = `/upload_token?file_name=${encodeURIComponent(file.name)}&file_type=${encodeURIComponent(file.type)}&file_size=${file.size}`; + 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) { @@ -176,6 +176,8 @@ function initUploadPage() { const uploadData = tokenData.result.data; const fileUuid = tokenData.result.file_uuid; + uploadData.fields["key"] = fileUuid; + delete uploadData.fields["Content-Type"]; setStatus('Загрузка файла на сервер...', ''); From 6e7741c9468d21b5d201d2378df16310f941f597 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 6 Jan 2026 18:15:40 +0300 Subject: [PATCH 16/29] Rework /get web endpiont --- frontend/script.js | 42 ++++++++++++++++++++++++++++++++++++++---- frontend/style.css | 4 ++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/frontend/script.js b/frontend/script.js index 96d2da5..6ebaca0 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -37,25 +37,59 @@ async function handleDownloadPage(fileUuid) { const data = await response.json(); if (data.error || !data.result || !data.result.data) { - downloadStatus.textContent = data.result?.comment || 'Ошибка при получении файла'; + downloadStatus.textContent = data.result?.comment || data.result || 'Ошибка при получении файла'; downloadStatus.className = 'status error'; + downloadStatus.style.display = 'block'; return; } const fileData = data.result.data; + + // Create download button handler + const handleDownload = async () => { + try { + // For presigned URLs, we fetch the file and create a blob URL + const response = await fetch(fileData.url); + if (!response.ok) { + throw new Error('Ошибка при загрузке файла'); + } + const blob = await response.blob(); + 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 the blob URL + window.URL.revokeObjectURL(blobUrl); + } catch (error) { + // Fallback: try direct navigation + window.location.href = fileData.url; + } + }; + downloadContent.innerHTML = `

${fileData.file_name}

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

- - Скачать файл - +
`; + + // Attach click handler to the download button + const downloadButton = document.getElementById('downloadButton'); + downloadButton.addEventListener('click', handleDownload); + downloadStatus.style.display = 'none'; } catch (error) { downloadStatus.textContent = `Ошибка: ${error.message}`; downloadStatus.className = 'status error'; + downloadStatus.style.display = 'block'; } } diff --git a/frontend/style.css b/frontend/style.css index 31371fd..09486a7 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -137,6 +137,7 @@ button:disabled { text-decoration: none; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease; + font-family: inherit; } .download-button:hover { @@ -144,3 +145,6 @@ button:disabled { box-shadow: 0 16px 32px -24px rgba(99, 102, 241, 0.8); } +.download-button:active { + transform: translateY(0); +} From fd347cf12a862cde738d329df4e9e0f4f12e6b84 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 6 Jan 2026 19:03:19 +0300 Subject: [PATCH 17/29] Frontend improvements --- frontend/index.html | 32 ++++++-- frontend/script.js | 118 +++++++++++++++++++++++++--- frontend/style.css | 187 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 316 insertions(+), 21 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index ff2caec..faa8be1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,25 +8,43 @@
-

Аналог DropMeFiles

-
+ +
+

Аналог DropMeFiles

+
- +
+ +
+
+
diff --git a/frontend/script.js b/frontend/script.js index 7340363..6d9d063 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -11,7 +11,8 @@ function formatBytes(bytes) { // Check if we're on the download page const path = window.location.pathname; -const downloadMatch = path.match(/^\/get\/([a-zA-Z0-9]{6})$/); +// 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 @@ -32,28 +33,41 @@ 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 = 'Сервис временно недоступен, приносим наши извинения.'; + downloadStatus.textContent = 'Сервис временно недоступен,\nприносим наши извинения.'; downloadStatus.className = 'status error'; downloadStatus.style.display = 'block'; + downloadContent.innerHTML = ''; return; } const data = await response.json(); - if (data.error || !data.result || !data.result.data) { - // Check if it's a 404 or file not found error - if (response.status === 404 || data.result?.comment?.includes('not found') || data.result?.comment?.includes('не найден')) { - downloadStatus.textContent = 'Мы не смогли ничего найти. Перепроверьте введенный адрес.'; - } else { - downloadStatus.textContent = 'Мы не смогли ничего найти. Перепроверьте введенный адрес.'; - } + // 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; } @@ -102,7 +116,7 @@ async function handleDownloadPage(fileUuid) { downloadStatus.style.display = 'none'; } catch (error) { // Network error or other connection issues - downloadStatus.textContent = 'Сервис временно недоступен, приносим наши извинения.'; + downloadStatus.textContent = 'Сервис временно недоступен,\nприносим наши извинения.'; downloadStatus.className = 'status error'; downloadStatus.style.display = 'block'; } diff --git a/frontend/style.css b/frontend/style.css index f9a7634..5e9a903 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -164,6 +164,10 @@ button:disabled { .status.error { color: #dc2626; + font-size: 18px; + font-weight: 500; + white-space: pre-line; + line-height: 1.6; } .status.success { From e67e223342d3531a2b1e3e26daae59c0ab0f96b6 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 6 Jan 2026 19:54:23 +0300 Subject: [PATCH 20/29] Bugfix redis.exceptions.TimeoutError --- app/redis_worker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/redis_worker.py b/app/redis_worker.py index 1d672ee..8c30ae3 100644 --- a/app/redis_worker.py +++ b/app/redis_worker.py @@ -13,7 +13,11 @@ class RedisWorker: port=int(os.getenv('REDIS_PORT')), username=os.getenv('REDIS_USERNAME'), password=os.getenv('REDIS_PASSWORD'), - decode_responses=True + 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')) From 91451c07b8cdbb90d2f8eb24476ac49bf40ffdf2 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 6 Jan 2026 20:31:59 +0300 Subject: [PATCH 21/29] Get real client IP --- app/main.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 497aab9..89c33ad 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ import uvicorn from datetime import datetime from fastapi import FastAPI, Response, status, Request +from starlette.middleware.base import BaseHTTPMiddleware from schemas.api_schemas import * from s3_worker import S3Worker @@ -26,6 +27,18 @@ app: FastAPI = FastAPI(title="DropMeFiles analog") s3_worker = S3Worker() redis_worker = RedisWorker() +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) + @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: @@ -36,7 +49,8 @@ async def get_upload_token(file_name: str, file_type: str, file_size: int, respo response.status_code = status.HTTP_400_BAD_REQUEST return BaseResponse(result="The file you are uploading is less than 1 byte, WTF?", error=True) - user_ip = request.client.host + user_ip = request.state.client_ip + print(user_ip) 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) From 8c7fda2069f732b51d4f17856a2bb6c59f937a10 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 6 Jan 2026 20:53:28 +0300 Subject: [PATCH 22/29] Download file in chunks --- frontend/index.html | 6 +++++ frontend/script.js | 62 ++++++++++++++++++++++++++++++++++++++++++--- frontend/style.css | 30 ++++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 773f38e..75a3a7d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -44,6 +44,12 @@
Загрузка...
+
diff --git a/frontend/script.js b/frontend/script.js index 6d9d063..eec6898 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -72,16 +72,61 @@ async function handleDownloadPage(fileUuid) { } const fileData = data.result.data; + const fileSize = parseInt(fileData.file_size); - // Create download button handler + // 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 { - // For presigned URLs, we fetch the file and create a blob URL + 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 blob = await response.blob(); + + 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'); @@ -91,9 +136,18 @@ async function handleDownloadPage(fileUuid) { link.click(); document.body.removeChild(link); - // Clean up the blob URL + // 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; } diff --git a/frontend/style.css b/frontend/style.css index 5e9a903..ac77033 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -333,3 +333,33 @@ button:disabled { 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; +} From 7ec0acf09b87cdd0062914864ef023e73f75bd31 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 6 Jan 2026 21:11:21 +0300 Subject: [PATCH 23/29] Add adaptive frontend --- frontend/index.html | 10 ++- frontend/script.js | 69 +++++++++++---- frontend/style.css | 205 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+), 18 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 75a3a7d..0111ab9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Аналог DropMeFiles - +
@@ -34,6 +34,12 @@
+ @@ -54,6 +60,6 @@ - + diff --git a/frontend/script.js b/frontend/script.js index eec6898..4408ffa 100644 --- a/frontend/script.js +++ b/frontend/script.js @@ -366,25 +366,57 @@ function initUploadPage() { uploadData.fields["key"] = fileUuid; delete uploadData.fields["Content-Type"]; - setStatus('Загрузка файла на сервер...', ''); + // 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 form-data - // Add all fields from the API response (order matters for S3, file should be last) - 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 uploadResponse = await fetch(uploadData.url, { - method: 'POST', - body: formData + // 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); }); - if (!uploadResponse.ok) { - throw new Error('Ошибка при загрузке файла на сервер'); - } + // Hide progress bar after successful upload + uploadProgress.style.display = 'none'; // Success! const downloadUrl = `${window.location.origin}/get/${fileUuid}`; @@ -405,6 +437,11 @@ function initUploadPage() { } showErrorPopup(errorMessage); setStatus('', ''); + // Hide progress bar on error + const uploadProgress = document.getElementById('uploadProgress'); + if (uploadProgress) { + uploadProgress.style.display = 'none'; + } } finally { uploadButton.disabled = false; } diff --git a/frontend/style.css b/frontend/style.css index ac77033..4ba8173 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -15,6 +15,13 @@ body { padding: 24px; } +@media (max-width: 768px) { + body { + padding: 16px; + justify-content: center; + } +} + .page-content { display: flex; flex-direction: column; @@ -30,6 +37,13 @@ h1 { 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; @@ -42,6 +56,14 @@ h1 { 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; @@ -55,6 +77,13 @@ h1 { overflow: hidden; } +@media (max-width: 768px) { + .dropzone { + padding: 32px 16px; + border-radius: 12px; + } +} + .dropzone:focus, .dropzone:focus-within { outline: none; @@ -130,6 +159,17 @@ button { 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) { @@ -204,6 +244,16 @@ button:disabled { 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 { @@ -246,6 +296,53 @@ button:disabled { } } +@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; @@ -282,6 +379,15 @@ button:disabled { 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; @@ -299,6 +405,16 @@ button:disabled { 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 { @@ -325,6 +441,16 @@ button:disabled { 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 { @@ -363,3 +489,82 @@ button:disabled { 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; + } +} From dbe9c45f1b3690930860fec93e8124bed30f6ef2 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Tue, 6 Jan 2026 21:13:13 +0300 Subject: [PATCH 24/29] Cringe --- frontend/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 0111ab9..241c91b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Аналог DropMeFiles - +
@@ -60,6 +60,6 @@
- + From 997204f414139d8adc4805e02ef908c841933c12 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sat, 10 Jan 2026 13:12:50 +0300 Subject: [PATCH 25/29] Add logger --- app/main.py | 59 +++++++++++++++++++++++++++++++++------------- app/pyproject.toml | 3 ++- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/app/main.py b/app/main.py index 89c33ad..7afb63b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import dotenv import os +import sys import random import string @@ -9,21 +10,31 @@ import uvicorn from datetime import datetime from fastapi import FastAPI, Response, status, Request from starlette.middleware.base import BaseHTTPMiddleware +from loguru import logger from schemas.api_schemas import * from s3_worker import S3Worker from redis_worker import RedisWorker dotenv.load_dotenv() + +logger.remove() +logger.add(f"{os.getenv("LOGS_PATH", "./logs")}/dmf-logs.log", + format="{time:DD-MM-YYYY HH:mm:ss.SSS}; {level}; {message}", level="DEBUG", + rotation="1 MB", compression="zip") +logger.add(sys.stdout, + level="DEBUG", + format="{time:HH:mm:ss} | {level} | {message}",) + disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" -app: FastAPI = FastAPI(title="DropMeFiles analog") -# app: FastAPI = FastAPI(title="DropMeFiles analog", -# summary="OpenAPI schema for \"DropMeFiles analog\" project!", -# version="0.1", -# 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") +app: FastAPI = FastAPI(title="DropMeFiles analog", + summary="OpenAPI schema for \"DropMeFiles analog\" project!", + version="0.1", + 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() @@ -39,55 +50,71 @@ class RealIPMiddleware(BaseHTTPMiddleware): 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 > int(os.getenv('MAX_FILES_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) - user_ip = request.state.client_ip - print(user_ip) 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 - return BaseResponse(result="Error generating S3 access token. Error: " + str(exception), error=True) + 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) - print(post_data) + + 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 - return BaseResponse(result="Error generating S3 access token. Error: " + str(exception), error=True) + 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')), host="0.0.0.0", log_level="debug") + config = uvicorn.Config("main:app", port=int(os.getenv('BACKEND_PORT')), host="0.0.0.0", log_level="critical") server = uvicorn.Server(config) await server.serve() diff --git a/app/pyproject.toml b/app/pyproject.toml index 63551fe..697363f 100644 --- a/app/pyproject.toml +++ b/app/pyproject.toml @@ -3,11 +3,12 @@ name = "drop-me-files-analog" version = "0.1.0" description = "" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.14" dependencies = [ "aiobotocore>=2.26.0", "dotenv>=0.9.9", "fastapi>=0.121.1", + "loguru>=0.7.3", "minio>=7.2.20", "pydantic>=2.12.5", "python-multipart>=0.0.20", From e92ade6c94dd20984f00aa42d6e6432c38543d61 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sat, 10 Jan 2026 13:48:18 +0300 Subject: [PATCH 26/29] Pre-merge edit --- app/.env-example | 18 ++++++++++++++++++ app/main.py | 6 ++++-- app/pyproject.toml | 1 - app/redis_worker.py | 9 +-------- app/s3_worker.py | 19 +------------------ docker/docker-compose.yml | 6 +++--- 6 files changed, 27 insertions(+), 32 deletions(-) create mode 100644 app/.env-example diff --git a/app/.env-example b/app/.env-example new file mode 100644 index 0000000..9216b31 --- /dev/null +++ b/app/.env-example @@ -0,0 +1,18 @@ +DISABLE_DOCS=false +BACKEND_PORT=5000 + +LOGS_PATH=./logs + +FILES_TTL=86400 # 1 Day +MAX_FILES_SIZE=1073741824 # 1GB + +S3_ACCESS_KEY_ID=Some_username +S3_SECRET_ACCESS_KEY=Some_password +S3_ENDPOINT_URL=Some_url + +BUCKET_NAME=drop-me-files + +REDIS_ADDRESS=Some_url +REDIS_PORT=6379 +REDIS_USERNAME=Some_username +REDIS_PASSWORD=Some_password \ No newline at end of file diff --git a/app/main.py b/app/main.py index 7afb63b..2255a60 100644 --- a/app/main.py +++ b/app/main.py @@ -29,7 +29,7 @@ logger.add(sys.stdout, disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true" app: FastAPI = FastAPI(title="DropMeFiles analog", summary="OpenAPI schema for \"DropMeFiles analog\" project!", - version="0.1", + 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", @@ -38,6 +38,8 @@ app: FastAPI = FastAPI(title="DropMeFiles analog", 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", "") @@ -55,7 +57,7 @@ app.add_middleware(RealIPMiddleware) 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 > int(os.getenv('MAX_FILES_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) diff --git a/app/pyproject.toml b/app/pyproject.toml index 697363f..8013285 100644 --- a/app/pyproject.toml +++ b/app/pyproject.toml @@ -9,7 +9,6 @@ dependencies = [ "dotenv>=0.9.9", "fastapi>=0.121.1", "loguru>=0.7.3", - "minio>=7.2.20", "pydantic>=2.12.5", "python-multipart>=0.0.20", "redis>=7.1.0", diff --git a/app/redis_worker.py b/app/redis_worker.py index 8c30ae3..d74a615 100644 --- a/app/redis_worker.py +++ b/app/redis_worker.py @@ -1,5 +1,4 @@ import os -from datetime import datetime import redis from dotenv import load_dotenv @@ -33,10 +32,4 @@ class RedisWorker: def get_record(self, file_uuid:str): - return self.client.hgetall(file_uuid) - - -if __name__ == "__main__": - worker = RedisWorker() - worker.create_record("127.0.0.1", "some.jpg", "QWERTY", "image/jpeg", datetime.now().isoformat(), file_size=1024) - print(worker.get_record("QWERTY")) + return self.client.hgetall(file_uuid) \ No newline at end of file diff --git a/app/s3_worker.py b/app/s3_worker.py index 43cf67a..79024b8 100644 --- a/app/s3_worker.py +++ b/app/s3_worker.py @@ -1,6 +1,5 @@ import os -import asyncio from contextlib import asynccontextmanager from dotenv import load_dotenv @@ -43,7 +42,6 @@ class S3Worker: 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", @@ -55,19 +53,4 @@ class S3Worker: ), }, ExpiresIn=expires_in, - ) - - -async def test_run(): - worker = S3Worker() - - url = await worker.generate_download_url("test.txt", "hello.txt") - print(url) - url = await worker.generate_upload_post("some.jpg", content_type="image/jpeg") - print(url) - url = await worker.generate_download_url("some.jpg", "image.jpg") - print(url) - - -if __name__ == "__main__": - asyncio.run(test_run()) \ No newline at end of file + ) \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 893ae04..58cf880 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.9" services: minio: - image: minio/minio:latest + image: minio/minio:RELEASE.2025-09-07T16-13-09Z container_name: dmf-minio-server restart: always ports: @@ -21,7 +21,7 @@ services: retries: 5 minio-init: - image: minio/mc:latest + image: minio/mc:RELEASE.2025-09-07T16-13-09Z depends_on: minio: condition: service_healthy @@ -64,6 +64,6 @@ services: interval: 30s timeout: 10s retries: 5 - restart: unless-stopped + restart: always tty: true stdin_open: true From c5c42e468979f078749c247b2e3e0d8a9378ea9a Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sat, 10 Jan 2026 18:05:50 +0300 Subject: [PATCH 27/29] Pre-merge edit --- app/main.py | 2 +- frontend/index.html | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 2255a60..385cf22 100644 --- a/app/main.py +++ b/app/main.py @@ -116,7 +116,7 @@ def health_check(): async def main(): - config = uvicorn.Config("main:app", port=int(os.getenv('BACKEND_PORT')), host="0.0.0.0", log_level="critical") + 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() diff --git a/frontend/index.html b/frontend/index.html index 241c91b..aa97202 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -41,6 +41,14 @@

0%

+

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

From a0806a39c07e3bc2656381690738a24569409457 Mon Sep 17 00:00:00 2001 From: IgorVolochay Date: Sat, 10 Jan 2026 18:06:52 +0300 Subject: [PATCH 28/29] Add README --- README.md | 193 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 324c044..72713ed 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,191 @@ -# 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) From cda9df3acc908722e7b1a737686955edde24cf51 Mon Sep 17 00:00:00 2001 From: Igor Volochay <44619012+IgorVolochay@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:51:59 +0300 Subject: [PATCH 29/29] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 72713ed..8c0eba4 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Drop-me-files-analog позволяет пользователям: **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**.