2025-09-03 10:21:17 +03:00
|
|
|
|
# 📤 Upload Client Guide - Quoter API
|
|
|
|
|
|
|
|
|
|
|
|
**Версия**: 0.6.2
|
|
|
|
|
|
**Дата**: 2025-01-28
|
|
|
|
|
|
**Статус**: 🚀 Production Ready
|
|
|
|
|
|
|
|
|
|
|
|
## 🎯 Обзор
|
|
|
|
|
|
|
|
|
|
|
|
Quoter предоставляет простой и надежный API для загрузки файлов с поддержкой:
|
|
|
|
|
|
- JWT аутентификации
|
|
|
|
|
|
- Квот пользователей (12 ГБ на пользователя)
|
|
|
|
|
|
- Множественных файлов в одном запросе
|
|
|
|
|
|
- Автоматического определения MIME типов
|
|
|
|
|
|
- Streaming загрузки с проверкой квот
|
|
|
|
|
|
|
|
|
|
|
|
## 🔗 Endpoints
|
|
|
|
|
|
|
|
|
|
|
|
### Base URL
|
|
|
|
|
|
```
|
|
|
|
|
|
Production: https://files.dscrs.site
|
|
|
|
|
|
Development: http://localhost:8080
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### API Endpoints
|
|
|
|
|
|
|
|
|
|
|
|
| Method | Endpoint | Описание |
|
|
|
|
|
|
|--------|----------|----------|
|
|
|
|
|
|
| `GET` | `/` | Информация о пользователе и квоте |
|
|
|
|
|
|
| `POST` | `/` | Загрузка файлов |
|
|
|
|
|
|
| `GET` | `/{filename}` | Получение файла |
|
|
|
|
|
|
|
|
|
|
|
|
## 🔐 Аутентификация
|
|
|
|
|
|
|
|
|
|
|
|
POST запросы требуют JWT токен в заголовке `Authorization`:
|
|
|
|
|
|
|
|
|
|
|
|
```http
|
|
|
|
|
|
Authorization: Bearer <your-jwt-token>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Формат токена
|
|
|
|
|
|
- JWT токен с claims: `{ user_id, username, exp?, iat? }`
|
|
|
|
|
|
- Минимальная длина: 100 символов
|
|
|
|
|
|
- Максимальная длина: 2048 символов
|
|
|
|
|
|
|
|
|
|
|
|
## 📊 Информация о пользователе
|
|
|
|
|
|
|
|
|
|
|
|
### GET /
|
|
|
|
|
|
|
|
|
|
|
|
Получает информацию о текущем пользователе и его квоте.
|
|
|
|
|
|
|
|
|
|
|
|
**Заголовки:**
|
|
|
|
|
|
```http
|
|
|
|
|
|
Authorization: Bearer <jwt-token>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Ответ:**
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
|
|
|
|
|
"user_id": "user123",
|
|
|
|
|
|
"username": "john_doe",
|
|
|
|
|
|
"token_type": "Bearer",
|
|
|
|
|
|
"created_at": "2025-01-28T10:00:00Z",
|
|
|
|
|
|
"last_activity": "2025-01-28T12:00:00Z",
|
|
|
|
|
|
"auth_data": {...},
|
|
|
|
|
|
"device_info": {...},
|
|
|
|
|
|
"quota": {
|
|
|
|
|
|
"current_quota": 1073741824,
|
|
|
|
|
|
"max_quota": 12884901888,
|
|
|
|
|
|
"usage_percentage": 8.33
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Коды ответов:**
|
|
|
|
|
|
- `200` - Успешно
|
|
|
|
|
|
- `401` - Неверный или истекший токен
|
|
|
|
|
|
|
|
|
|
|
|
## 📤 Загрузка файлов
|
|
|
|
|
|
|
|
|
|
|
|
### POST /
|
|
|
|
|
|
|
|
|
|
|
|
Загружает один или несколько файлов.
|
|
|
|
|
|
|
|
|
|
|
|
**Заголовки:**
|
|
|
|
|
|
```http
|
|
|
|
|
|
Authorization: Bearer <jwt-token>
|
|
|
|
|
|
Content-Type: multipart/form-data
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Параметры:**
|
|
|
|
|
|
- `file` (multipart) - Файл для загрузки (можно несколько)
|
|
|
|
|
|
|
|
|
|
|
|
**Лимиты:**
|
|
|
|
|
|
- Максимальный размер одного файла: **500 МБ**
|
|
|
|
|
|
- Максимальная квота пользователя: **12 ГБ**
|
|
|
|
|
|
- Поддерживаемые форматы: изображения, аудио, видео, документы
|
|
|
|
|
|
|
|
|
|
|
|
**Ответ (один файл):**
|
|
|
|
|
|
```
|
|
|
|
|
|
filename-uuid.ext
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Ответ (несколько файлов):**
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
|
|
|
|
|
"uploaded_files": [
|
|
|
|
|
|
"file1-uuid.ext",
|
|
|
|
|
|
"file2-uuid.ext"
|
|
|
|
|
|
],
|
|
|
|
|
|
"count": 2
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Коды ответов:**
|
|
|
|
|
|
- `200` - Файл(ы) успешно загружены
|
|
|
|
|
|
- `400` - Нет файлов или все файлы пустые
|
|
|
|
|
|
- `401` - Неверный токен авторизации
|
|
|
|
|
|
- `413` - Превышен лимит размера файла или квоты
|
|
|
|
|
|
- `415` - Неподдерживаемый формат файла
|
|
|
|
|
|
- `500` - Ошибка сервера
|
|
|
|
|
|
|
|
|
|
|
|
## 📁 Получение файлов
|
|
|
|
|
|
|
|
|
|
|
|
### GET /{filename}
|
|
|
|
|
|
|
|
|
|
|
|
Получает загруженный файл.
|
|
|
|
|
|
|
|
|
|
|
|
**Параметры:**
|
|
|
|
|
|
- `filename` - Имя файла (UUID + расширение)
|
|
|
|
|
|
|
|
|
|
|
|
**Заголовки (опционально):**
|
|
|
|
|
|
```http
|
|
|
|
|
|
If-None-Match: "etag-value"
|
|
|
|
|
|
Range: bytes=0-1023
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**Ответ:**
|
|
|
|
|
|
- `200` - Файл найден
|
|
|
|
|
|
- `304` - Файл не изменился (если передан ETag)
|
|
|
|
|
|
- `404` - Файл не найден
|
|
|
|
|
|
- `206` - Частичный контент (если передан Range)
|
|
|
|
|
|
|
|
|
|
|
|
## 💻 Примеры кода
|
|
|
|
|
|
|
|
|
|
|
|
### JavaScript/TypeScript
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
class QuoterUploadClient {
|
|
|
|
|
|
private baseUrl: string;
|
|
|
|
|
|
private token: string;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(baseUrl: string, token: string) {
|
|
|
|
|
|
this.baseUrl = baseUrl;
|
|
|
|
|
|
this.token = token;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Получение информации о пользователе
|
|
|
|
|
|
async getUserInfo() {
|
|
|
|
|
|
const response = await fetch(`${this.baseUrl}/`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${this.token}`
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await response.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка одного файла
|
|
|
|
|
|
async uploadFile(file: File): Promise<string> {
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`${this.baseUrl}/`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${this.token}`
|
|
|
|
|
|
},
|
|
|
|
|
|
body: formData
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const errorText = await response.text();
|
|
|
|
|
|
throw new Error(`Upload failed: ${response.status} ${errorText}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await response.text();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка нескольких файлов
|
|
|
|
|
|
async uploadFiles(files: File[]): Promise<{uploaded_files: string[], count: number}> {
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
|
files.forEach(file => formData.append('file', file));
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`${this.baseUrl}/`, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${this.token}`
|
|
|
|
|
|
},
|
|
|
|
|
|
body: formData
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const errorText = await response.text();
|
|
|
|
|
|
throw new Error(`Upload failed: ${response.status} ${errorText}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await response.json();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Получение файла
|
|
|
|
|
|
async getFile(filename: string): Promise<Blob> {
|
|
|
|
|
|
const response = await fetch(`${this.baseUrl}/${filename}`, {
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Authorization': `Bearer ${this.token}`
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error(`File not found: ${response.status}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await response.blob();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Использование
|
|
|
|
|
|
const client = new QuoterUploadClient('https://files.dscrs.site', 'your-jwt-token');
|
|
|
|
|
|
|
|
|
|
|
|
// Получение информации о пользователе
|
|
|
|
|
|
const userInfo = await client.getUserInfo();
|
|
|
|
|
|
console.log(`Квота: ${userInfo.quota.usage_percentage.toFixed(1)}%`);
|
|
|
|
|
|
|
|
|
|
|
|
// Загрузка файла
|
|
|
|
|
|
const fileInput = document.getElementById('fileInput') as HTMLInputElement;
|
|
|
|
|
|
const file = fileInput.files[0];
|
|
|
|
|
|
const filename = await client.uploadFile(file);
|
|
|
|
|
|
console.log(`Файл загружен: ${filename}`);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Python
|
|
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
|
import requests
|
|
|
|
|
|
import json
|
|
|
|
|
|
from typing import List, Dict, Any
|
|
|
|
|
|
|
|
|
|
|
|
class QuoterUploadClient:
|
|
|
|
|
|
def __init__(self, base_url: str, token: str):
|
|
|
|
|
|
self.base_url = base_url.rstrip('/')
|
|
|
|
|
|
self.token = token
|
|
|
|
|
|
self.headers = {'Authorization': f'Bearer {token}'}
|
|
|
|
|
|
|
|
|
|
|
|
def get_user_info(self) -> Dict[str, Any]:
|
|
|
|
|
|
"""Получение информации о пользователе"""
|
|
|
|
|
|
response = requests.get(f'{self.base_url}/', headers=self.headers)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
|
|
|
|
|
|
def upload_file(self, file_path: str) -> str:
|
|
|
|
|
|
"""Загрузка одного файла"""
|
|
|
|
|
|
with open(file_path, 'rb') as f:
|
|
|
|
|
|
files = {'file': f}
|
|
|
|
|
|
response = requests.post(
|
|
|
|
|
|
f'{self.base_url}/',
|
|
|
|
|
|
headers=self.headers,
|
|
|
|
|
|
files=files
|
|
|
|
|
|
)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
return response.text.strip()
|
|
|
|
|
|
|
|
|
|
|
|
def upload_files(self, file_paths: List[str]) -> Dict[str, Any]:
|
|
|
|
|
|
"""Загрузка нескольких файлов"""
|
|
|
|
|
|
files = []
|
|
|
|
|
|
for path in file_paths:
|
|
|
|
|
|
files.append(('file', open(path, 'rb')))
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
response = requests.post(
|
|
|
|
|
|
f'{self.base_url}/',
|
|
|
|
|
|
headers=self.headers,
|
|
|
|
|
|
files=files
|
|
|
|
|
|
)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
# Закрываем все файлы
|
|
|
|
|
|
for _, file_obj in files:
|
|
|
|
|
|
file_obj.close()
|
|
|
|
|
|
|
|
|
|
|
|
def get_file(self, filename: str, save_path: str = None) -> bytes:
|
|
|
|
|
|
"""Получение файла"""
|
|
|
|
|
|
response = requests.get(
|
|
|
|
|
|
f'{self.base_url}/{filename}',
|
|
|
|
|
|
headers=self.headers
|
|
|
|
|
|
)
|
|
|
|
|
|
response.raise_for_status()
|
|
|
|
|
|
|
|
|
|
|
|
if save_path:
|
|
|
|
|
|
with open(save_path, 'wb') as f:
|
|
|
|
|
|
f.write(response.content)
|
|
|
|
|
|
|
|
|
|
|
|
return response.content
|
|
|
|
|
|
|
|
|
|
|
|
# Использование
|
|
|
|
|
|
client = QuoterUploadClient('https://files.dscrs.site', 'your-jwt-token')
|
|
|
|
|
|
|
|
|
|
|
|
# Получение информации о пользователе
|
|
|
|
|
|
user_info = client.get_user_info()
|
|
|
|
|
|
print(f"Квота: {user_info['quota']['usage_percentage']:.1f}%")
|
|
|
|
|
|
|
|
|
|
|
|
# Загрузка файла
|
|
|
|
|
|
filename = client.upload_file('/path/to/file.jpg')
|
|
|
|
|
|
print(f"Файл загружен: {filename}")
|
|
|
|
|
|
|
|
|
|
|
|
# Получение файла
|
|
|
|
|
|
file_content = client.get_file(filename, '/path/to/downloaded_file.jpg')
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### cURL
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# Получение информации о пользователе
|
|
|
|
|
|
curl -H "Authorization: Bearer your-jwt-token" \
|
|
|
|
|
|
https://files.dscrs.site/
|
|
|
|
|
|
|
|
|
|
|
|
# Загрузка одного файла
|
|
|
|
|
|
curl -X POST \
|
|
|
|
|
|
-H "Authorization: Bearer your-jwt-token" \
|
|
|
|
|
|
-F "file=@/path/to/file.jpg" \
|
|
|
|
|
|
https://files.dscrs.site/
|
|
|
|
|
|
|
|
|
|
|
|
# Загрузка нескольких файлов
|
|
|
|
|
|
curl -X POST \
|
|
|
|
|
|
-H "Authorization: Bearer your-jwt-token" \
|
|
|
|
|
|
-F "file=@/path/to/file1.jpg" \
|
|
|
|
|
|
-F "file=@/path/to/file2.mp3" \
|
|
|
|
|
|
https://files.dscrs.site/
|
|
|
|
|
|
|
|
|
|
|
|
# Получение файла
|
|
|
|
|
|
curl -H "Authorization: Bearer your-jwt-token" \
|
|
|
|
|
|
-o downloaded_file.jpg \
|
|
|
|
|
|
https://files.dscrs.site/filename-uuid.jpg
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 🚨 Обработка ошибок
|
|
|
|
|
|
|
|
|
|
|
|
### Типичные ошибки
|
|
|
|
|
|
|
|
|
|
|
|
| Код | Описание | Решение |
|
|
|
|
|
|
|-----|----------|---------|
|
|
|
|
|
|
| `401` | Неверный токен | Проверить валидность JWT токена |
|
|
|
|
|
|
| `413` | Превышена квота | Удалить старые файлы или увеличить квоту |
|
|
|
|
|
|
| `413` | Файл слишком большой | Разделить файл на части |
|
|
|
|
|
|
| `415` | Неподдерживаемый формат | Проверить MIME тип файла |
|
|
|
|
|
|
| `500` | Ошибка сервера | Повторить запрос позже |
|
|
|
|
|
|
|
|
|
|
|
|
### Пример обработки ошибок
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
async function uploadWithRetry(client: QuoterUploadClient, file: File, maxRetries = 3) {
|
|
|
|
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await client.uploadFile(file);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error.message.includes('413')) {
|
|
|
|
|
|
throw new Error('Файл слишком большой или квота превышена');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (error.message.includes('401')) {
|
|
|
|
|
|
throw new Error('Токен авторизации недействителен');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (attempt === maxRetries) {
|
|
|
|
|
|
throw error;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Ждем перед повторной попыткой
|
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 📋 Поддерживаемые форматы
|
|
|
|
|
|
|
|
|
|
|
|
### Изображения
|
|
|
|
|
|
- JPEG, PNG, GIF, WebP, HEIC, BMP, TIFF
|
|
|
|
|
|
|
|
|
|
|
|
### Аудио
|
|
|
|
|
|
- MP3, WAV, AAC, M4A, OGG, FLAC
|
|
|
|
|
|
|
|
|
|
|
|
### Видео
|
|
|
|
|
|
- MP4, AVI, MOV, WebM, MKV
|
|
|
|
|
|
|
|
|
|
|
|
### Документы
|
|
|
|
|
|
- PDF, DOC, DOCX, TXT, RTF
|
|
|
|
|
|
|
|
|
|
|
|
## 🔧 Конфигурация
|
|
|
|
|
|
|
|
|
|
|
|
### Environment Variables
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# Обязательные
|
2025-09-30 21:46:47 +03:00
|
|
|
|
JWT_SECRET_KEY=your-jwt-secret-key
|
2025-09-03 10:21:17 +03:00
|
|
|
|
REDIS_URL=redis://localhost:6379
|
|
|
|
|
|
STORJ_ACCESS_KEY=your-storj-access-key
|
|
|
|
|
|
STORJ_SECRET_KEY=your-storj-secret-key
|
|
|
|
|
|
STORJ_BUCKET=your-bucket-name
|
|
|
|
|
|
|
|
|
|
|
|
# Опциональные
|
|
|
|
|
|
PORT=8080
|
|
|
|
|
|
CORS_DOWNLOAD_ORIGINS=https://discours.io,https://*.discours.io
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Лимиты
|
|
|
|
|
|
|
|
|
|
|
|
```rust
|
|
|
|
|
|
// Максимальный размер одного файла
|
|
|
|
|
|
const MAX_SINGLE_FILE_BYTES: u64 = 500 * 1024 * 1024; // 500 МБ
|
|
|
|
|
|
|
|
|
|
|
|
// Максимальная квота пользователя
|
|
|
|
|
|
const MAX_USER_QUOTA_BYTES: u64 = 12 * 1024 * 1024 * 1024; // 12 ГБ
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 🧪 Тестирование
|
|
|
|
|
|
|
|
|
|
|
|
### Проверка подключения
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# Проверка доступности API
|
|
|
|
|
|
curl -I https://files.dscrs.site/
|
|
|
|
|
|
|
|
|
|
|
|
# Проверка аутентификации
|
|
|
|
|
|
curl -H "Authorization: Bearer your-token" \
|
|
|
|
|
|
https://files.dscrs.site/
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Тестовые файлы
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
# Создание тестового файла
|
|
|
|
|
|
echo "Test content" > test.txt
|
|
|
|
|
|
|
|
|
|
|
|
# Загрузка тестового файла
|
|
|
|
|
|
curl -X POST \
|
|
|
|
|
|
-H "Authorization: Bearer your-token" \
|
|
|
|
|
|
-F "file=@test.txt" \
|
|
|
|
|
|
https://files.dscrs.site/
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 📚 Дополнительные ресурсы
|
|
|
|
|
|
|
|
|
|
|
|
- [API Reference](README.md)
|
|
|
|
|
|
- [Setup Guide](SETUP.md)
|
|
|
|
|
|
- [Features Overview](features.md)
|
|
|
|
|
|
- [CORS Configuration](architecture.md)
|
|
|
|
|
|
|
|
|
|
|
|
## 🆘 Поддержка
|
|
|
|
|
|
|
|
|
|
|
|
При возникновении проблем:
|
|
|
|
|
|
|
|
|
|
|
|
1. Проверьте валидность JWT токена
|
|
|
|
|
|
2. Убедитесь в правильности Content-Type
|
|
|
|
|
|
3. Проверьте размер файла и квоту пользователя
|
|
|
|
|
|
4. Изучите логи сервера для детальной диагностики
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
**💡 Совет**: Используйте streaming загрузку для больших файлов и всегда проверяйте квоту пользователя перед загрузкой.
|