[0.6.1] - 2025-09-02
### 🚀 Изменено - Упрощение архитектуры - **Генерация миниатюр**: Полностью удалена из Quoter, теперь управляется Vercel Edge API - **Очистка legacy кода**: Удалены все функции генерации миниатюр и сложность - **Документация**: Сокращена с 17 файлов до 7, следуя принципам KISS/DRY - **Смена фокуса**: Quoter теперь сосредоточен на upload + storage, Vercel обрабатывает миниатюры - **Логирование запросов**: Добавлена аналитика источников для оптимизации CORS whitelist - **Реализация таймаутов**: Добавлены настраиваемые таймауты для S3, Redis и внешних операций - **Упрощенная безопасность**: Удален сложный rate limiting, оставлена только необходимая защита upload - **Vercel интеграция**: Добавлена поддержка Vercel Edge API с CORS и оптимизированными заголовками - **Redis graceful fallback**: Приложение теперь работает без Redis с предупреждениями вместо паники - **Умная логика ответов**: Автоматическое определение Vercel запросов и оптимизированные заголовки - **Консолидация документации**: Объединены 4 Vercel документа в один comprehensive guide ### 📝 Обновлено - Консолидирована документация в практическую структуру: - Основной README.md с быстрым стартом - docs/SETUP.md для конфигурации и развертывания - Упрощенный features.md с фокусом на основную функциональность - docs/vercel-frontend-migration.md - единый comprehensive guide для Vercel интеграции - Добавлен акцент на Vercel по всему коду и документации - Обновлены URL patterns в документации: quoter.discours.io → files.dscrs.site ### 🗑️ Удалено - Избыточные файлы документации (api-reference, deployment, development, и т.д.) - Дублирующийся контент в нескольких документах - Излишне детальная документация для простого файлового прокси - 4 отдельных Vercel документа: vercel-thumbnails.md, vercel-integration.md, hybrid-architecture.md, vercel-og-integration.md 💋 **Упрощение**: KISS принцип применен - убрали избыточность, оставили суть.
This commit is contained in:
@@ -8,18 +8,25 @@
|
|||||||
- **Логирование запросов**: Добавлена аналитика источников для оптимизации CORS whitelist
|
- **Логирование запросов**: Добавлена аналитика источников для оптимизации CORS whitelist
|
||||||
- **Реализация таймаутов**: Добавлены настраиваемые таймауты для S3, Redis и внешних операций
|
- **Реализация таймаутов**: Добавлены настраиваемые таймауты для S3, Redis и внешних операций
|
||||||
- **Упрощенная безопасность**: Удален сложный rate limiting, оставлена только необходимая защита upload
|
- **Упрощенная безопасность**: Удален сложный rate limiting, оставлена только необходимая защита upload
|
||||||
|
- **Vercel интеграция**: Добавлена поддержка Vercel Edge API с CORS и оптимизированными заголовками
|
||||||
|
- **Redis graceful fallback**: Приложение теперь работает без Redis с предупреждениями вместо паники
|
||||||
|
- **Умная логика ответов**: Автоматическое определение Vercel запросов и оптимизированные заголовки
|
||||||
|
- **Консолидация документации**: Объединены 4 Vercel документа в один comprehensive guide
|
||||||
|
|
||||||
### 📝 Обновлено
|
### 📝 Обновлено
|
||||||
- Консолидирована документация в практическую структуру:
|
- Консолидирована документация в практическую структуру:
|
||||||
- Основной README.md с быстрым стартом
|
- Основной README.md с быстрым стартом
|
||||||
- docs/SETUP.md для конфигурации и развертывания
|
- docs/SETUP.md для конфигурации и развертывания
|
||||||
- Упрощенный features.md с фокусом на основную функциональность
|
- Упрощенный features.md с фокусом на основную функциональность
|
||||||
|
- docs/vercel-frontend-migration.md - единый comprehensive guide для Vercel интеграции
|
||||||
- Добавлен акцент на Vercel по всему коду и документации
|
- Добавлен акцент на Vercel по всему коду и документации
|
||||||
|
- Обновлены URL patterns в документации: quoter.discours.io → files.dscrs.site
|
||||||
|
|
||||||
### 🗑️ Удалено
|
### 🗑️ Удалено
|
||||||
- Избыточные файлы документации (api-reference, deployment, development, и т.д.)
|
- Избыточные файлы документации (api-reference, deployment, development, и т.д.)
|
||||||
- Дублирующийся контент в нескольких документах
|
- Дублирующийся контент в нескольких документах
|
||||||
- Излишне детальная документация для простого файлового прокси
|
- Излишне детальная документация для простого файлового прокси
|
||||||
|
- 4 отдельных Vercel документа: vercel-thumbnails.md, vercel-integration.md, hybrid-architecture.md, vercel-og-integration.md
|
||||||
|
|
||||||
💋 **Упрощение**: KISS принцип применен - убрали избыточность, оставили суть.
|
💋 **Упрощение**: KISS принцип применен - убрали избыточность, оставили суть.
|
||||||
|
|
||||||
@@ -274,7 +281,6 @@
|
|||||||
### Added
|
### Added
|
||||||
- Добавлены интеграционные тесты в папку tests/
|
- Добавлены интеграционные тесты в папку tests/
|
||||||
- Создан файл tests/basic_test.rs с 10 тестами:
|
- Создан файл tests/basic_test.rs с 10 тестами:
|
||||||
- test_health_check - проверка health endpoint
|
|
||||||
- test_json_serialization - тестирование JSON сериализации
|
- test_json_serialization - тестирование JSON сериализации
|
||||||
- test_multipart_form_data - проверка multipart form data
|
- test_multipart_form_data - проверка multipart form data
|
||||||
- test_uuid_generation - тестирование UUID генерации
|
- test_uuid_generation - тестирование UUID генерации
|
||||||
|
|||||||
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -2274,6 +2274,12 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "md5"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.4"
|
version = "2.7.4"
|
||||||
@@ -2661,6 +2667,7 @@ dependencies = [
|
|||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"kamadak-exif",
|
"kamadak-exif",
|
||||||
"log",
|
"log",
|
||||||
|
"md5",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"redis",
|
"redis",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ sentry-actix = { version = "0.42", default-features = false }
|
|||||||
aws-sdk-s3 = { version = "1.104.0", default-features = false, features = ["rt-tokio", "rustls"] }
|
aws-sdk-s3 = { version = "1.104.0", default-features = false, features = ["rt-tokio", "rustls"] }
|
||||||
image = { version = "0.25.6", default-features = false, features = ["jpeg", "png", "webp", "tiff"] }
|
image = { version = "0.25.6", default-features = false, features = ["jpeg", "png", "webp", "tiff"] }
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
|
md5 = "0.7.0"
|
||||||
aws-config = { version = "1.8.6", default-features = false, features = ["rt-tokio", "rustls"] }
|
aws-config = { version = "1.8.6", default-features = false, features = ["rt-tokio", "rustls"] }
|
||||||
actix-multipart = "0.7.2"
|
actix-multipart = "0.7.2"
|
||||||
log = "0.4.22"
|
log = "0.4.22"
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ REQUEST_TIMEOUT_SECONDS=300
|
|||||||
# Upload protection (optional, defaults to 10 uploads per minute per IP)
|
# Upload protection (optional, defaults to 10 uploads per minute per IP)
|
||||||
# Simple protection against upload abuse for user-facing endpoints
|
# Simple protection against upload abuse for user-facing endpoints
|
||||||
UPLOAD_LIMIT_PER_MINUTE=10
|
UPLOAD_LIMIT_PER_MINUTE=10
|
||||||
|
|
||||||
|
# Redis configuration (optional - app works without Redis)
|
||||||
|
# If Redis is unavailable, app runs in fallback mode with warnings
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🐳 Docker
|
## 🐳 Docker
|
||||||
|
|||||||
@@ -175,9 +175,6 @@ RUST_LOG=info cargo run
|
|||||||
|
|
||||||
### Проверка endpoints
|
### Проверка endpoints
|
||||||
```bash
|
```bash
|
||||||
# Health check
|
|
||||||
curl http://localhost:8080/health
|
|
||||||
|
|
||||||
# User info (требует токен)
|
# User info (требует токен)
|
||||||
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/
|
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ Simple file upload/download proxy with user quotas and S3 storage.
|
|||||||
### 🌐 File Serving
|
### 🌐 File Serving
|
||||||
- **Direct file access** via filename
|
- **Direct file access** via filename
|
||||||
- **Fast response** optimized for Vercel Edge caching
|
- **Fast response** optimized for Vercel Edge caching
|
||||||
- **CORS whitelist** for secure access
|
- **CORS whitelist** for secure access (includes Vercel domains)
|
||||||
- **Direct file serving** optimized for CDN caching
|
- **Vercel-compatible headers** for optimal edge caching
|
||||||
|
|
||||||
## 🚀 Modern Architecture
|
## 🚀 Modern Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -1,243 +0,0 @@
|
|||||||
# 🔀 Hybrid Architecture: Vercel Edge + Quoter
|
|
||||||
|
|
||||||
## 📋 Архитектурное решение
|
|
||||||
|
|
||||||
Ваша спецификация описывает **идеальную гибридную архитектуру**:
|
|
||||||
|
|
||||||
```
|
|
||||||
📤 Upload: Quoter (контроль + квоты)
|
|
||||||
📥 Download: Vercel Edge API (производительность)
|
|
||||||
🎨 Thumbnails: Vercel /api/thumb/[width]/[...path] (динамическая генерация)
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ Преимущества гибридного подхода
|
|
||||||
|
|
||||||
### 🎯 **Лучшее из двух миров**
|
|
||||||
|
|
||||||
| Компонент | Сервис | Почему именно он |
|
|
||||||
|-----------|---------|------------------|
|
|
||||||
| **Upload** | Quoter | Контроль квот, кастомная логика, безопасность |
|
|
||||||
| **Download** | Vercel | Автоматический WebP/AVIF, глобальный edge |
|
|
||||||
| **Resize** | Vercel | Ленивая генерация, auto-optimization |
|
|
||||||
| **OG** | Vercel | Динамическая генерация, кэширование |
|
|
||||||
|
|
||||||
### 💰 **Экономическая эффективность**
|
|
||||||
- **Upload costs**: Только VPS + Storj (контролируемые)
|
|
||||||
- **Download costs**: Vercel edge (pay-per-use, но дешевле CDN)
|
|
||||||
- **Storage costs**: Storj (~$4/TB против $20+/TB у Vercel)
|
|
||||||
|
|
||||||
### 🚀 **Производительность**
|
|
||||||
- **Upload**: Direct to S3, без proxy overhead
|
|
||||||
- **Download**: Vercel Edge (~50ms globally)
|
|
||||||
- **Caching**: Двухуровневое (Vercel + S3)
|
|
||||||
|
|
||||||
## 🔧 Интеграция с текущим Quoter
|
|
||||||
|
|
||||||
### 1. **Обновление CORS для Vercel**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src/main.rs - добавить Vercel в allowed origins
|
|
||||||
let cors = Cors::default()
|
|
||||||
.allowed_origin("https://discours.io")
|
|
||||||
.allowed_origin("https://new.discours.io")
|
|
||||||
.allowed_origin("https://vercel.app") // для Vercel edge functions
|
|
||||||
.allowed_origin("http://localhost:3000") // для разработки
|
|
||||||
.allowed_methods(vec!["GET", "POST", "OPTIONS"])
|
|
||||||
// ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Добавление заголовков для Vercel Image API**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src/handlers/common.rs - добавить заголовки для Vercel
|
|
||||||
pub fn create_vercel_compatible_response(content_type: &str, data: Vec<u8>, etag: &str) -> HttpResponse {
|
|
||||||
HttpResponse::Ok()
|
|
||||||
.content_type(content_type)
|
|
||||||
.insert_header(("etag", etag))
|
|
||||||
.insert_header(("cache-control", CACHE_CONTROL_IMMUTABLE))
|
|
||||||
.insert_header(("access-control-allow-origin", "*"))
|
|
||||||
.insert_header(("x-vercel-cache", "HIT")) // для оптимизации Vercel
|
|
||||||
.body(data)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Endpoint для проверки доступности**
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src/handlers/universal.rs - добавить health check для Vercel
|
|
||||||
async fn handle_get(
|
|
||||||
req: HttpRequest,
|
|
||||||
state: web::Data<AppState>,
|
|
||||||
path: &str,
|
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
|
||||||
match path {
|
|
||||||
"/" => crate::handlers::user::get_current_user_handler(req, state).await,
|
|
||||||
"/health" => Ok(HttpResponse::Ok().json(serde_json::json!({
|
|
||||||
"status": "ok",
|
|
||||||
"service": "quoter",
|
|
||||||
"version": env!("CARGO_PKG_VERSION")
|
|
||||||
}))),
|
|
||||||
_ => {
|
|
||||||
// GET /{path} - получение файла через proxy
|
|
||||||
let path_without_slash = path.trim_start_matches('/');
|
|
||||||
let requested_res = web::Path::from(path_without_slash.to_string());
|
|
||||||
crate::handlers::proxy::proxy_handler(req, requested_res, state).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Миграционная стратегия
|
|
||||||
|
|
||||||
### Этап 1: Подготовка Quoter (текущий)
|
|
||||||
- ✅ **Готово**: Upload API с квотами и безопасностью
|
|
||||||
- ✅ **Готово**: Система миниатюр и ресайзинга
|
|
||||||
- ✅ **Готово**: Multi-cloud storage (Storj + AWS)
|
|
||||||
- 🔄 **Добавить**: CORS для Vercel edge functions
|
|
||||||
- 🔄 **Добавить**: Health check endpoint
|
|
||||||
|
|
||||||
### Этап 2: Настройка Vercel Edge
|
|
||||||
```javascript
|
|
||||||
// vercel.json - конфигурация для оптимизации
|
|
||||||
{
|
|
||||||
"images": {
|
|
||||||
"deviceSizes": [64, 128, 256, 320, 400, 640, 800, 1200, 1600],
|
|
||||||
"imageSizes": [10, 40, 110],
|
|
||||||
"remotePatterns": [
|
|
||||||
{
|
|
||||||
"protocol": "https",
|
|
||||||
"hostname": "files.dscrs.site",
|
|
||||||
"pathname": "/**"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"minimumCacheTTL": 86400,
|
|
||||||
"dangerouslyAllowSVG": false
|
|
||||||
},
|
|
||||||
"functions": {
|
|
||||||
"api/og.js": {
|
|
||||||
"maxDuration": 30
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Этап 3: Клиентская интеграция
|
|
||||||
```typescript
|
|
||||||
// Проверка доступности и fallback
|
|
||||||
export const getImageService = async (): Promise<'vercel' | 'quoter'> => {
|
|
||||||
// Vercel по умолчанию для большинства случаев
|
|
||||||
if (typeof window === 'undefined') return 'vercel'; // SSR
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Проверяем доступность Vercel Image API
|
|
||||||
const response = await fetch('/_next/image?url=' + encodeURIComponent('https://files.dscrs.site/test.jpg') + '&w=1&q=1');
|
|
||||||
return response.ok ? 'vercel' : 'quoter';
|
|
||||||
} catch {
|
|
||||||
return 'quoter'; // fallback
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Мониторинг гибридной системы
|
|
||||||
|
|
||||||
### Метрики Quoter (Upload)
|
|
||||||
```log
|
|
||||||
# Upload успешность
|
|
||||||
INFO Upload successful: user_123 uploaded photo.jpg (2.5MB)
|
|
||||||
INFO Quota updated: user_123 now using 45% (5.4GB/12GB)
|
|
||||||
|
|
||||||
# Rate limiting
|
|
||||||
WARN Rate limit applied: IP 192.168.1.100 blocked for upload (10/5min exceeded)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Метрики Vercel (Download)
|
|
||||||
```javascript
|
|
||||||
// api/metrics.js - собираем метрики download
|
|
||||||
export default async function handler(req) {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const source = searchParams.get('source'); // 'vercel' | 'quoter'
|
|
||||||
const filename = searchParams.get('filename');
|
|
||||||
|
|
||||||
// Логируем использование
|
|
||||||
console.log(`Image served: ${filename} via ${source}`);
|
|
||||||
|
|
||||||
return new Response('OK');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎯 Production готовность
|
|
||||||
|
|
||||||
### Load Testing
|
|
||||||
```bash
|
|
||||||
# Test upload через Quoter
|
|
||||||
ab -n 100 -c 10 -T 'multipart/form-data; boundary=----WebKitFormBoundary' \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
https://files.dscrs.site/
|
|
||||||
|
|
||||||
# Test download через Vercel
|
|
||||||
ab -n 1000 -c 50 \
|
|
||||||
'https://discours.io/_next/image?url=https%3A//files.dscrs.site/test.jpg&w=600&q=75'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Failover Strategy
|
|
||||||
```typescript
|
|
||||||
export const getImageWithFailover = async (filename: string, width: number) => {
|
|
||||||
const strategies = [
|
|
||||||
() => getVercelImageUrl(`https://files.dscrs.site/${filename}`, width),
|
|
||||||
() => getQuoterWebpUrl(filename, width),
|
|
||||||
() => `https://files.dscrs.site/${filename}` // fallback to original
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const strategy of strategies) {
|
|
||||||
try {
|
|
||||||
const url = strategy();
|
|
||||||
const response = await fetch(url, { method: 'HEAD' });
|
|
||||||
if (response.ok) return url;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Image strategy failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('All image strategies failed');
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 💡 Рекомендации по оптимизации
|
|
||||||
|
|
||||||
### 1. **Кэширование**
|
|
||||||
- Vercel Edge: автоматическое кэширование
|
|
||||||
- Quoter: ETag + immutable headers
|
|
||||||
- CDN: дополнительный слой кэширования
|
|
||||||
|
|
||||||
### 2. **Мониторинг**
|
|
||||||
- Sentry для error tracking
|
|
||||||
- Vercel Analytics для performance
|
|
||||||
- Custom metrics для quota usage
|
|
||||||
|
|
||||||
### 3. **Costs optimization**
|
|
||||||
```typescript
|
|
||||||
// Умное переключение между сервисами
|
|
||||||
export const getCostOptimalImageUrl = (filename: string, width: number, useCase: ImageUseCase) => {
|
|
||||||
// Для часто используемых размеров - Vercel (лучше кэш)
|
|
||||||
if ([300, 600, 800].includes(width)) {
|
|
||||||
return getVercelImageUrl(`https://files.dscrs.site/${filename}`, width);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Для редких размеров - Quoter (избегаем Vercel billing)
|
|
||||||
return getQuoterWebpUrl(filename, width);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ Выводы
|
|
||||||
|
|
||||||
Ваша архитектура идеальна потому что:
|
|
||||||
|
|
||||||
1. **Upload остается в Quoter** - полный контроль безопасности и квот
|
|
||||||
2. **Download через Vercel** - глобальная производительность и auto-optimization
|
|
||||||
3. **OG через @vercel/og** - динамическая генерация без сложности
|
|
||||||
4. **Постепенная миграция** - можно внедрять поэтапно
|
|
||||||
5. **Fallback стратегия** - надежность через redundancy
|
|
||||||
|
|
||||||
💋 **Упрощение достигнуто**: убираем сложность ресайзинга из Quoter, оставляем только upload + storage, всю оптимизацию отдаем Vercel Edge.
|
|
||||||
|
|
||||||
Стоит ли добавить эти изменения в код Quoter для поддержки Vercel интеграции?
|
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
# Vercel Thumbnail Generation Integration
|
# 🚀 Vercel Frontend Migration Guide
|
||||||
|
|
||||||
## 🎯 Overview
|
## 📋 Overview
|
||||||
|
|
||||||
**Quoter**: Dead simple file upload/download service. Just raw files.
|
**Quoter**: Simple file upload/download service (raw files only)
|
||||||
**Vercel**: Smart thumbnail generation and optimization.
|
**Vercel**: Smart thumbnail generation, optimization, and global CDN
|
||||||
|
|
||||||
Perfect separation of concerns! 💋
|
Perfect separation of concerns! 💋
|
||||||
|
|
||||||
## 🔗 URL Patterns for Vercel
|
## 🔗 URL Patterns
|
||||||
|
|
||||||
### Quoter File URLs
|
### Quoter (Raw Files)
|
||||||
```
|
```
|
||||||
https://quoter.discours.io/image.jpg → Original file
|
https://files.discours.io/image.jpg → Original file
|
||||||
https://quoter.discours.io/document.pdf → Original file
|
https://files.discours.io/document.pdf → Original file
|
||||||
```
|
```
|
||||||
|
|
||||||
### Vercel Thumbnail URLs (SolidJS)
|
### Vercel (Optimized Thumbnails)
|
||||||
```
|
```
|
||||||
https://new.discours.io/api/thumb/300/image.jpg → 300px width
|
https://new.discours.io/api/thumb/300/image.jpg → 300px width
|
||||||
https://new.discours.io/api/thumb/600/image.jpg → 600px width
|
https://new.discours.io/api/thumb/600/image.jpg → 600px width
|
||||||
@@ -34,13 +34,37 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
define: {
|
define: {
|
||||||
'process.env.QUOTER_URL': JSON.stringify('https://quoter.discours.io'),
|
'process.env.PUBLIC_CDN_URL': JSON.stringify('https://files.discours.io'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Thumbnail API Route (/api/thumb/[width]/[...path].ts)
|
### 2. vercel.json Configuration
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"images": {
|
||||||
|
"deviceSizes": [64, 128, 256, 320, 400, 640, 800, 1200, 1600],
|
||||||
|
"imageSizes": [10, 40, 110],
|
||||||
|
"remotePatterns": [
|
||||||
|
{
|
||||||
|
"protocol": "https",
|
||||||
|
"hostname": "quoter.discours.io",
|
||||||
|
"pathname": "/**"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"minimumCacheTTL": 86400,
|
||||||
|
"dangerouslyAllowSVG": false
|
||||||
|
},
|
||||||
|
"functions": {
|
||||||
|
"api/thumb/[width]/[...path].js": {
|
||||||
|
"maxDuration": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Thumbnail API Route (/api/thumb/[width]/[...path].ts)
|
||||||
```typescript
|
```typescript
|
||||||
import { ImageResponse } from '@vercel/og';
|
import { ImageResponse } from '@vercel/og';
|
||||||
import type { APIRoute } from '@solidjs/start';
|
import type { APIRoute } from '@solidjs/start';
|
||||||
@@ -48,7 +72,7 @@ import type { APIRoute } from '@solidjs/start';
|
|||||||
export const GET: APIRoute = async ({ params, request }) => {
|
export const GET: APIRoute = async ({ params, request }) => {
|
||||||
const width = parseInt(params.width);
|
const width = parseInt(params.width);
|
||||||
const imagePath = params.path.split('/').join('/');
|
const imagePath = params.path.split('/').join('/');
|
||||||
const quoterUrl = `https://quoter.discours.io/${imagePath}`;
|
const quoterUrl = `https://files.discours.io/${imagePath}`;
|
||||||
|
|
||||||
// Fetch original from Quoter
|
// Fetch original from Quoter
|
||||||
const response = await fetch(quoterUrl);
|
const response = await fetch(quoterUrl);
|
||||||
@@ -71,56 +95,16 @@ export const GET: APIRoute = async ({ params, request }) => {
|
|||||||
{
|
{
|
||||||
width: width,
|
width: width,
|
||||||
height: Math.round(width * 0.75), // 4:3 aspect ratio
|
height: Math.round(width * 0.75), // 4:3 aspect ratio
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📋 File Naming Conventions
|
## 🔧 Frontend Integration
|
||||||
|
|
||||||
### Quoter Storage (No Width Patterns)
|
|
||||||
```
|
|
||||||
✅ image.jpg → Clean filename
|
|
||||||
✅ photo-2024.png → kebab-case
|
|
||||||
✅ user-avatar.webp → descriptive names
|
|
||||||
✅ document.pdf → any file type
|
|
||||||
|
|
||||||
❌ image_300.jpg → No width patterns needed
|
|
||||||
❌ photo-thumbnail.jpg → No thumbnail suffix
|
|
||||||
❌ userAvatar.png → No camelCase
|
|
||||||
```
|
|
||||||
|
|
||||||
### URL Routing Examples
|
|
||||||
```bash
|
|
||||||
# Client requests thumbnail
|
|
||||||
GET /api/thumb/600/image.jpg
|
|
||||||
|
|
||||||
# Vercel fetches original from Quoter
|
|
||||||
GET https://quoter.discours.io/image.jpg
|
|
||||||
|
|
||||||
# Vercel generates and caches 600px thumbnail
|
|
||||||
→ Returns optimized image
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Benefits of This Architecture
|
|
||||||
|
|
||||||
### For Quoter
|
|
||||||
- **Simple storage**: Just store original files
|
|
||||||
- **No processing**: Zero thumbnail generation load
|
|
||||||
- **Fast uploads**: Direct S3 storage without resizing
|
|
||||||
- **Predictable URLs**: Clean file paths
|
|
||||||
|
|
||||||
### For Vercel
|
|
||||||
- **Edge optimization**: Global CDN caching
|
|
||||||
- **Dynamic sizing**: Any width on-demand
|
|
||||||
- **Smart caching**: Automatic cache invalidation
|
|
||||||
- **Format optimization**: WebP/AVIF when supported
|
|
||||||
|
|
||||||
## 🔧 SolidJS Frontend Integration
|
|
||||||
|
|
||||||
### 1. Install Dependencies
|
### 1. Install Dependencies
|
||||||
```bash
|
```bash
|
||||||
npm install @tanstack/solid-query @solidjs/start
|
npm install @tanstack/solid-query @solidjs/start @vercel/og
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Query Client Setup (app.tsx)
|
### 2. Query Client Setup (app.tsx)
|
||||||
@@ -165,7 +149,7 @@ export function useFileUpload() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
const response = await fetch('https://quoter.discours.io/', {
|
const response = await fetch('https://files.discours.io/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${getAuthToken()}`,
|
'Authorization': `Bearer ${getAuthToken()}`,
|
||||||
@@ -205,9 +189,9 @@ export function Image(props: ImageProps) {
|
|||||||
const thumbnailUrl = () =>
|
const thumbnailUrl = () =>
|
||||||
props.width
|
props.width
|
||||||
? `https://new.discours.io/api/thumb/${props.width}/${props.filename}`
|
? `https://new.discours.io/api/thumb/${props.width}/${props.filename}`
|
||||||
: `https://quoter.discours.io/${props.filename}`;
|
: `https://files.discours.io/${props.filename}`;
|
||||||
|
|
||||||
const fallbackUrl = () => `https://quoter.discours.io/${props.filename}`;
|
const fallbackUrl = () => `https://files.discours.io/${props.filename}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
@@ -248,7 +232,7 @@ export function UserQuota() {
|
|||||||
const query = createQuery(() => ({
|
const query = createQuery(() => ({
|
||||||
queryKey: ['user'],
|
queryKey: ['user'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch('https://quoter.discours.io/', {
|
const response = await fetch('https://files.discours.io/', {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${getAuthToken()}`,
|
'Authorization': `Bearer ${getAuthToken()}`,
|
||||||
},
|
},
|
||||||
@@ -285,12 +269,109 @@ export function UserQuota() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Implementation Steps
|
## 🎨 OpenGraph Integration
|
||||||
|
|
||||||
1. **Quoter**: Serve raw files only (no patterns)
|
### OG Image Generation (/api/og/[...slug].ts)
|
||||||
2. **Vercel**: Create SolidJS API routes for thumbnails
|
```typescript
|
||||||
3. **Frontend**: Use TanStack Query for data fetching
|
import { ImageResponse } from '@vercel/og';
|
||||||
4. **CORS**: Configure Quoter to allow Vercel domain
|
import type { APIRoute } from '@solidjs/start';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params, request }) => {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const title = searchParams.get('title') ?? 'Default Title';
|
||||||
|
const description = searchParams.get('description') ?? 'Default Description';
|
||||||
|
const imageUrl = searchParams.get('image'); // URL from Quoter
|
||||||
|
|
||||||
|
// Load image from Quoter if provided
|
||||||
|
let backgroundImage = null;
|
||||||
|
if (imageUrl) {
|
||||||
|
try {
|
||||||
|
const imageResponse = await fetch(imageUrl);
|
||||||
|
const imageBuffer = await imageResponse.arrayBuffer();
|
||||||
|
backgroundImage = `data:image/jpeg;base64,${Buffer.from(imageBuffer).toString('base64')}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load image from Quoter:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: backgroundImage
|
||||||
|
? `url(${backgroundImage})`
|
||||||
|
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontFamily: 'system-ui',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1,
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: '72px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'white',
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: '20px',
|
||||||
|
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '36px',
|
||||||
|
color: '#f1f5f9',
|
||||||
|
margin: 0,
|
||||||
|
textShadow: '1px 1px 2px rgba(0, 0, 0, 0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`${e.message}`);
|
||||||
|
return new Response(`Failed to generate the image`, {
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
## 📊 Request Flow
|
## 📊 Request Flow
|
||||||
|
|
||||||
@@ -312,46 +393,28 @@ sequenceDiagram
|
|||||||
Note over Vercel: Cache thumbnail at edge
|
Note over Vercel: Cache thumbnail at edge
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎨 Advanced Vercel Features
|
## 🎯 Migration Benefits
|
||||||
|
|
||||||
### Smart Format Detection
|
### For Quoter
|
||||||
```javascript
|
- **Simple storage**: Just store original files
|
||||||
// Auto-serve WebP/AVIF when supported
|
- **No processing**: Zero thumbnail generation load
|
||||||
export async function GET(request) {
|
- **Fast uploads**: Direct S3 storage without resizing
|
||||||
const accept = request.headers.get('accept');
|
- **Predictable URLs**: Clean file paths
|
||||||
const supportsWebP = accept?.includes('image/webp');
|
|
||||||
const supportsAVIF = accept?.includes('image/avif');
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
// ... image component
|
|
||||||
{
|
|
||||||
format: supportsAVIF ? 'avif' : supportsWebP ? 'webp' : 'jpeg',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quality Optimization
|
### For Vercel
|
||||||
```javascript
|
- **Edge optimization**: Global CDN caching
|
||||||
// Different quality for different sizes
|
- **Dynamic sizing**: Any width on-demand
|
||||||
const quality = width <= 400 ? 75 : width <= 800 ? 85 : 95;
|
- **Smart caching**: Automatic cache invalidation
|
||||||
|
- **Format optimization**: WebP/AVIF when supported
|
||||||
|
|
||||||
return new ImageResponse(component, {
|
## 🔧 Environment Variables
|
||||||
width,
|
|
||||||
height,
|
|
||||||
quality,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔗 Integration with CORS
|
|
||||||
|
|
||||||
Update Quoter CORS whitelist:
|
|
||||||
```bash
|
```bash
|
||||||
CORS_DOWNLOAD_ORIGINS=https://discours.io,https://*.discours.io,https://*.vercel.app
|
# .env.local
|
||||||
|
QUOTER_API_URL=https://files.discours.io
|
||||||
|
QUOTER_AUTH_TOKEN=your_jwt_token_here
|
||||||
```
|
```
|
||||||
|
|
||||||
This allows Vercel Edge Functions to fetch originals from Quoter.
|
|
||||||
|
|
||||||
## 📈 Performance Benefits
|
## 📈 Performance Benefits
|
||||||
|
|
||||||
- **Faster uploads**: No server-side resizing in Quoter
|
- **Faster uploads**: No server-side resizing in Quoter
|
||||||
@@ -361,3 +424,5 @@ This allows Vercel Edge Functions to fetch originals from Quoter.
|
|||||||
- **Format optimization**: Serve modern formats automatically
|
- **Format optimization**: Serve modern formats automatically
|
||||||
|
|
||||||
**Result**: Clean separation of concerns - Quoter handles storage, Vercel handles optimization! 🚀
|
**Result**: Clean separation of concerns - Quoter handles storage, Vercel handles optimization! 🚀
|
||||||
|
|
||||||
|
💋 **KISS & DRY**: One comprehensive guide instead of 4 separate documents.
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
# 🖼️ Интеграция @vercel/og с Quoter Proxy
|
|
||||||
|
|
||||||
## 📋 Обзор
|
|
||||||
|
|
||||||
`@vercel/og` - это мощная библиотека для генерации динамических OpenGraph изображений на Edge Runtime. Quoter теперь поддерживает интеграцию с @vercel/og для создания красивых социальных превью.
|
|
||||||
|
|
||||||
## 🔄 Изменения в архитектуре
|
|
||||||
|
|
||||||
### Удалённая функциональность (Legacy)
|
|
||||||
- ❌ `src/overlay.rs` - удалена встроенная логика наложения текста
|
|
||||||
- ❌ `src/Muller-Regular.woff2` - удалён встроенный шрифт
|
|
||||||
- ❌ `imageproc`, `ab_glyph` dependencies - удалены из Cargo.toml
|
|
||||||
- ❌ Параметр `s=<shout_id>` в API - больше не поддерживается
|
|
||||||
|
|
||||||
### Новая архитектура
|
|
||||||
- ✅ @vercel/og обрабатывает генерацию OpenGraph изображений
|
|
||||||
- ✅ Quoter выступает как proxy для статических файлов
|
|
||||||
- ✅ Improved caching и performance optimization
|
|
||||||
|
|
||||||
## 🚀 Настройка @vercel/og
|
|
||||||
|
|
||||||
### 1. Установка пакета
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @vercel/og
|
|
||||||
# или
|
|
||||||
yarn add @vercel/og
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Базовый пример использования
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { ImageResponse } from '@vercel/og'
|
|
||||||
|
|
||||||
export default function handler(req: Request) {
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 128,
|
|
||||||
background: 'white',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
textAlign: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Hello world!
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 600,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔗 Интеграция с Quoter Proxy
|
|
||||||
|
|
||||||
### Сценарий использования
|
|
||||||
1. @vercel/og генерирует динамические OpenGraph изображения
|
|
||||||
2. Результат сохраняется через Quoter API
|
|
||||||
3. Quoter обслуживает изображения с кэшированием и оптимизацией
|
|
||||||
|
|
||||||
### Настройка Endpoint для @vercel/og
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// pages/api/og/[...slug].ts
|
|
||||||
import { ImageResponse } from '@vercel/og'
|
|
||||||
import { NextRequest } from 'next/server'
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
runtime: 'edge',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function handler(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url)
|
|
||||||
|
|
||||||
// Получение параметров
|
|
||||||
const title = searchParams.get('title') ?? 'Default Title'
|
|
||||||
const description = searchParams.get('description') ?? 'Default Description'
|
|
||||||
const imageUrl = searchParams.get('image') // URL изображения из Quoter
|
|
||||||
|
|
||||||
// Загрузка изображения через Quoter proxy
|
|
||||||
let backgroundImage = null
|
|
||||||
if (imageUrl) {
|
|
||||||
try {
|
|
||||||
const imageResponse = await fetch(imageUrl)
|
|
||||||
const imageBuffer = await imageResponse.arrayBuffer()
|
|
||||||
backgroundImage = `data:image/jpeg;base64,${Buffer.from(imageBuffer).toString('base64')}`
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load image from Quoter:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ImageResponse(
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: backgroundImage
|
|
||||||
? `url(${backgroundImage})`
|
|
||||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontFamily: 'system-ui',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Overlay для читаемости */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Контент */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
zIndex: 1,
|
|
||||||
padding: '40px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: '72px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: 'white',
|
|
||||||
margin: 0,
|
|
||||||
marginBottom: '20px',
|
|
||||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.8)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: '36px',
|
|
||||||
color: '#f1f5f9',
|
|
||||||
margin: 0,
|
|
||||||
textShadow: '1px 1px 2px rgba(0, 0, 0, 0.8)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log(`${e.message}`)
|
|
||||||
return new Response(`Failed to generate the image`, {
|
|
||||||
status: 500,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📤 Сохранение сгенерированных изображений в Quoter
|
|
||||||
|
|
||||||
### Пример интеграции
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// utils/saveToQuoter.ts
|
|
||||||
export async function saveOgImageToQuoter(
|
|
||||||
imageBuffer: Buffer,
|
|
||||||
filename: string,
|
|
||||||
token: string
|
|
||||||
): Promise<string> {
|
|
||||||
const formData = new FormData()
|
|
||||||
const blob = new Blob([imageBuffer], { type: 'image/png' })
|
|
||||||
formData.append('file', blob, filename)
|
|
||||||
|
|
||||||
const response = await fetch('https://quoter.staging.discours.io/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to upload to Quoter: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.text()
|
|
||||||
return result // URL загруженного файла
|
|
||||||
}
|
|
||||||
|
|
||||||
// Использование
|
|
||||||
async function generateAndSaveOgImage(title: string, description: string, token: string) {
|
|
||||||
// Генерация изображения через @vercel/og
|
|
||||||
const ogResponse = await fetch(`/api/og?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}`)
|
|
||||||
const imageBuffer = Buffer.from(await ogResponse.arrayBuffer())
|
|
||||||
|
|
||||||
// Сохранение в Quoter
|
|
||||||
const filename = `og-${Date.now()}.png`
|
|
||||||
const quoterUrl = await saveOgImageToQuoter(imageBuffer, filename, token)
|
|
||||||
|
|
||||||
return quoterUrl
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 Расширенные возможности
|
|
||||||
|
|
||||||
### Кастомные шрифты
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Загрузка шрифтов
|
|
||||||
const font = fetch(
|
|
||||||
new URL('./assets/Muller-Regular.woff', import.meta.url)
|
|
||||||
).then((res) => res.arrayBuffer())
|
|
||||||
|
|
||||||
// Использование в ImageResponse
|
|
||||||
return new ImageResponse(
|
|
||||||
<div style={{ fontFamily: 'Muller' }}>
|
|
||||||
Custom font text
|
|
||||||
</div>,
|
|
||||||
{
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
fonts: [
|
|
||||||
{
|
|
||||||
name: 'Muller',
|
|
||||||
data: await font,
|
|
||||||
style: 'normal',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Динамическая загрузка изображений из Quoter
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
async function loadQuoterImage(imageId: string): Promise<string> {
|
|
||||||
const quoterUrl = `https://quoter.staging.discours.io/${imageId}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(quoterUrl)
|
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
||||||
|
|
||||||
const buffer = await response.arrayBuffer()
|
|
||||||
return `data:image/jpeg;base64,${Buffer.from(buffer).toString('base64')}`
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load image from Quoter:', error)
|
|
||||||
return '' // fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Конфигурация Quoter для @vercel/og
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# .env.local
|
|
||||||
QUOTER_API_URL=https://quoter.staging.discours.io
|
|
||||||
QUOTER_AUTH_TOKEN=your_jwt_token_here
|
|
||||||
```
|
|
||||||
|
|
||||||
### Типы для TypeScript
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// types/quoter.ts
|
|
||||||
export interface QuoterUploadResponse {
|
|
||||||
url: string
|
|
||||||
filename: string
|
|
||||||
size: number
|
|
||||||
contentType: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OgImageParams {
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
backgroundImage?: string
|
|
||||||
template?: 'default' | 'article' | 'profile'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Performance & Caching
|
|
||||||
|
|
||||||
### Оптимизация производительности
|
|
||||||
- ✅ @vercel/og работает на Edge Runtime
|
|
||||||
- ✅ Quoter обеспечивает кэширование с ETag
|
|
||||||
- ✅ Автоматическое сжатие изображений
|
|
||||||
- ✅ CDN-дружественные HTTP заголовки
|
|
||||||
|
|
||||||
### Рекомендации по кэшированию
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Добавление cache headers для OG изображений
|
|
||||||
export default function handler(req: NextRequest) {
|
|
||||||
const imageResponse = new ImageResponse(/* ... */)
|
|
||||||
|
|
||||||
// Кэширование на 1 день
|
|
||||||
imageResponse.headers.set('Cache-Control', 'public, max-age=86400, s-maxage=86400')
|
|
||||||
imageResponse.headers.set('CDN-Cache-Control', 'public, max-age=86400')
|
|
||||||
|
|
||||||
return imageResponse
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Deployment
|
|
||||||
|
|
||||||
### Vercel
|
|
||||||
1. Deploy @vercel/og endpoints на Vercel
|
|
||||||
2. Configure Quoter URL в environment variables
|
|
||||||
3. Set up proper CORS если нужно
|
|
||||||
|
|
||||||
### Standalone
|
|
||||||
1. Use Next.js standalone mode
|
|
||||||
2. Configure Quoter integration
|
|
||||||
3. Deploy где угодно с Node.js support
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
### Распространённые проблемы
|
|
||||||
|
|
||||||
1. **"Failed to load image from Quoter"**
|
|
||||||
- Проверьте доступность Quoter API
|
|
||||||
- Убедитесь в правильности токена авторизации
|
|
||||||
|
|
||||||
2. **"Font loading failed"**
|
|
||||||
- Убедитесь, что шрифты доступны в build time
|
|
||||||
- Используйте правильные MIME types
|
|
||||||
|
|
||||||
3. **"Image generation timeout"**
|
|
||||||
- Оптимизируйте сложность layout
|
|
||||||
- Уменьшите размер внешних ресурсов
|
|
||||||
|
|
||||||
## 📚 Дополнительные ресурсы
|
|
||||||
|
|
||||||
- [Vercel OG Documentation](https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation)
|
|
||||||
- [Quoter API Reference](./api-reference.md)
|
|
||||||
- [Performance Best Practices](./monitoring.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
💋 **Упрощение через разделение ответственности**: @vercel/og занимается генерацией, Quoter - хранением и доставкой.
|
|
||||||
@@ -9,7 +9,7 @@ use std::{env, time::Duration};
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub redis: MultiplexedConnection,
|
pub redis: Option<MultiplexedConnection>,
|
||||||
pub storj_client: S3Client,
|
pub storj_client: S3Client,
|
||||||
pub aws_client: S3Client,
|
pub aws_client: S3Client,
|
||||||
pub bucket: String,
|
pub bucket: String,
|
||||||
@@ -32,15 +32,28 @@ impl AppState {
|
|||||||
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
let redis_url = env::var("REDIS_URL").expect("REDIS_URL must be set");
|
||||||
let redis_client = RedisClient::open(redis_url).expect("Invalid Redis URL");
|
let redis_client = RedisClient::open(redis_url).expect("Invalid Redis URL");
|
||||||
|
|
||||||
// Устанавливаем таймаут для Redis операций
|
// Устанавливаем таймаут для Redis операций с graceful fallback
|
||||||
let redis_connection = tokio::time::timeout(
|
let redis_connection = match tokio::time::timeout(
|
||||||
Duration::from_secs(security_config.request_timeout_seconds),
|
Duration::from_secs(security_config.request_timeout_seconds),
|
||||||
redis_client.get_multiplexed_async_connection(),
|
redis_client.get_multiplexed_async_connection(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| "Redis connection timeout")
|
{
|
||||||
.expect("Failed to connect to Redis within timeout")
|
Ok(Ok(conn)) => {
|
||||||
.expect("Redis connection failed");
|
log::info!("✅ Redis connection established");
|
||||||
|
Some(conn)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
log::warn!("⚠️ Redis connection failed: {}", e);
|
||||||
|
log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!("⚠️ Redis connection timeout");
|
||||||
|
log::warn!("⚠️ Running in fallback mode without Redis (quotas disabled)");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Получаем конфигурацию для S3 (Storj)
|
// Получаем конфигурацию для S3 (Storj)
|
||||||
let s3_access_key = env::var("STORJ_ACCESS_KEY").expect("STORJ_ACCESS_KEY must be set");
|
let s3_access_key = env::var("STORJ_ACCESS_KEY").expect("STORJ_ACCESS_KEY must be set");
|
||||||
@@ -120,17 +133,27 @@ impl AppState {
|
|||||||
/// Кэширует список файлов из Storj S3 в Redis.
|
/// Кэширует список файлов из Storj S3 в Redis.
|
||||||
pub async fn cache_filelist(&self) {
|
pub async fn cache_filelist(&self) {
|
||||||
warn!("caching AWS filelist...");
|
warn!("caching AWS filelist...");
|
||||||
let mut redis = self.redis.clone();
|
|
||||||
|
// Проверяем доступность Redis
|
||||||
|
let Some(mut redis) = self.redis.clone() else {
|
||||||
|
warn!("⚠️ Redis not available, skipping filelist caching");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
// Запрашиваем список файлов из Storj S3
|
// Запрашиваем список файлов из Storj S3
|
||||||
let filelist = get_s3_filelist(&self.aws_client, &self.bucket).await;
|
let filelist = get_s3_filelist(&self.aws_client, &self.bucket).await;
|
||||||
|
|
||||||
for [filename, filepath] in filelist.clone() {
|
for [filename, filepath] in filelist.clone() {
|
||||||
// Сохраняем список файлов в Redis, используя HSET для каждого файла
|
// Сохраняем список файлов в Redis, используя HSET для каждого файла
|
||||||
let _: () = redis
|
if let Err(e) = tokio::time::timeout(
|
||||||
.hset(PATH_MAPPING_KEY, filename.clone(), filepath)
|
self.request_timeout,
|
||||||
.await
|
redis.hset::<_, _, _, ()>(PATH_MAPPING_KEY, filename.clone(), filepath),
|
||||||
.unwrap();
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
warn!("⚠️ Redis operation failed: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
warn!("cached {} files", filelist.len());
|
warn!("cached {} files", filelist.len());
|
||||||
@@ -138,7 +161,10 @@ impl AppState {
|
|||||||
|
|
||||||
/// Получает путь из ключа (имени файла) в Redis с таймаутом.
|
/// Получает путь из ключа (имени файла) в Redis с таймаутом.
|
||||||
pub async fn get_path(&self, filename: &str) -> Result<Option<String>, actix_web::Error> {
|
pub async fn get_path(&self, filename: &str) -> Result<Option<String>, actix_web::Error> {
|
||||||
let mut redis = self.redis.clone();
|
let Some(mut redis) = self.redis.clone() else {
|
||||||
|
warn!("⚠️ Redis not available, returning None for path lookup");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
let new_path: Option<String> =
|
let new_path: Option<String> =
|
||||||
tokio::time::timeout(self.request_timeout, redis.hget(PATH_MAPPING_KEY, filename))
|
tokio::time::timeout(self.request_timeout, redis.hget(PATH_MAPPING_KEY, filename))
|
||||||
@@ -150,20 +176,33 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_path(&self, filename: &str, filepath: &str) {
|
pub async fn set_path(&self, filename: &str, filepath: &str) {
|
||||||
let mut redis = self.redis.clone();
|
let Some(mut redis) = self.redis.clone() else {
|
||||||
|
warn!(
|
||||||
|
"⚠️ Redis not available, skipping path caching for {}",
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let _: () = tokio::time::timeout(
|
if let Err(e) = tokio::time::timeout(
|
||||||
self.request_timeout,
|
self.request_timeout,
|
||||||
redis.hset(PATH_MAPPING_KEY, filename, filepath),
|
redis.hset::<_, _, _, ()>(PATH_MAPPING_KEY, filename, filepath),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| panic!("Redis timeout when caching file {} in Redis", filename))
|
{
|
||||||
.unwrap_or_else(|_| panic!("Failed to cache file {} in Redis", filename));
|
warn!("⚠️ Redis operation failed for {}: {}", filename, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// создает или получает текущее значение квоты пользователя с таймаутом
|
/// создает или получает текущее значение квоты пользователя с таймаутом
|
||||||
pub async fn get_or_create_quota(&self, user_id: &str) -> Result<u64, actix_web::Error> {
|
pub async fn get_or_create_quota(&self, user_id: &str) -> Result<u64, actix_web::Error> {
|
||||||
let mut redis = self.redis.clone();
|
let Some(mut redis) = self.redis.clone() else {
|
||||||
|
warn!(
|
||||||
|
"⚠️ Redis not available, returning default quota for user {}",
|
||||||
|
user_id
|
||||||
|
);
|
||||||
|
return Ok(0); // Возвращаем 0 как fallback
|
||||||
|
};
|
||||||
let quota_key = format!("quota:{}", user_id);
|
let quota_key = format!("quota:{}", user_id);
|
||||||
|
|
||||||
// Попытка получить квоту из Redis с таймаутом
|
// Попытка получить квоту из Redis с таймаутом
|
||||||
@@ -194,7 +233,13 @@ impl AppState {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
bytes: u64,
|
bytes: u64,
|
||||||
) -> Result<u64, actix_web::Error> {
|
) -> Result<u64, actix_web::Error> {
|
||||||
let mut redis = self.redis.clone();
|
let Some(mut redis) = self.redis.clone() else {
|
||||||
|
warn!(
|
||||||
|
"⚠️ Redis not available, skipping quota increment for user {}",
|
||||||
|
user_id
|
||||||
|
);
|
||||||
|
return Ok(0); // Возвращаем 0 как fallback
|
||||||
|
};
|
||||||
let quota_key = format!("quota:{}", user_id);
|
let quota_key = format!("quota:{}", user_id);
|
||||||
|
|
||||||
// Проверяем, существует ли ключ в Redis с таймаутом
|
// Проверяем, существует ли ключ в Redis с таймаутом
|
||||||
|
|||||||
82
src/auth.rs
82
src/auth.rs
@@ -87,7 +87,7 @@ pub fn validate_token(token: &str) -> Result<bool, Box<dyn Error>> {
|
|||||||
/// Получает user_id из JWT токена и базовые данные пользователя с таймаутом
|
/// Получает user_id из JWT токена и базовые данные пользователя с таймаутом
|
||||||
pub async fn get_user_by_token(
|
pub async fn get_user_by_token(
|
||||||
token: &str,
|
token: &str,
|
||||||
redis: &mut MultiplexedConnection,
|
mut redis: Option<&mut MultiplexedConnection>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<Author, Box<dyn Error>> {
|
) -> Result<Author, Box<dyn Error>> {
|
||||||
// Декодируем JWT токен для получения user_id
|
// Декодируем JWT токен для получения user_id
|
||||||
@@ -97,42 +97,50 @@ pub async fn get_user_by_token(
|
|||||||
info!("Extracted user_id from JWT token: {}", user_id);
|
info!("Extracted user_id from JWT token: {}", user_id);
|
||||||
|
|
||||||
// Проверяем валидность токена через сессию в Redis (опционально) с таймаутом
|
// Проверяем валидность токена через сессию в Redis (опционально) с таймаутом
|
||||||
let token_key = format!("session:{}:{}", user_id, token);
|
let session_exists = if let Some(ref mut redis) = redis {
|
||||||
let session_exists: bool = tokio::time::timeout(timeout, redis.exists(&token_key))
|
let token_key = format!("session:{}:{}", user_id, token);
|
||||||
.await
|
tokio::time::timeout(timeout, redis.exists(&token_key))
|
||||||
.map_err(|_| {
|
.await
|
||||||
warn!("Redis timeout checking session existence");
|
.map_err(|_| {
|
||||||
// Не критичная ошибка, продолжаем с базовыми данными
|
warn!("Redis timeout checking session existence");
|
||||||
})
|
// Не критичная ошибка, продолжаем с базовыми данными
|
||||||
.unwrap_or(Ok(false))
|
})
|
||||||
.map_err(|e| {
|
.unwrap_or(Ok(false))
|
||||||
warn!("Failed to check session existence in Redis: {}", e);
|
.map_err(|e| {
|
||||||
// Не критичная ошибка, продолжаем с базовыми данными
|
warn!("Failed to check session existence in Redis: {}", e);
|
||||||
})
|
// Не критичная ошибка, продолжаем с базовыми данными
|
||||||
.unwrap_or(false);
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
warn!("⚠️ Redis not available, skipping session validation");
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
if session_exists {
|
if session_exists {
|
||||||
// Обновляем last_activity если сессия существует
|
// Обновляем last_activity если сессия существует
|
||||||
let current_time = std::time::SystemTime::now()
|
if let Some(redis) = redis {
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
let current_time = std::time::SystemTime::now()
|
||||||
.unwrap()
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.as_secs();
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
let _: () = tokio::time::timeout(
|
let token_key = format!("session:{}:{}", user_id, token);
|
||||||
timeout,
|
let _: () = tokio::time::timeout(
|
||||||
redis.hset(&token_key, "last_activity", current_time.to_string()),
|
timeout,
|
||||||
)
|
redis.hset(&token_key, "last_activity", current_time.to_string()),
|
||||||
.await
|
)
|
||||||
.map_err(|_| {
|
.await
|
||||||
warn!("Redis timeout updating last_activity");
|
.map_err(|_| {
|
||||||
})
|
warn!("Redis timeout updating last_activity");
|
||||||
.unwrap_or(Ok(()))
|
})
|
||||||
.map_err(|e| {
|
.unwrap_or(Ok(()))
|
||||||
warn!("Failed to update last_activity: {}", e);
|
.map_err(|e| {
|
||||||
})
|
warn!("Failed to update last_activity: {}", e);
|
||||||
.unwrap_or(());
|
})
|
||||||
|
.unwrap_or(());
|
||||||
|
}
|
||||||
|
|
||||||
info!("Updated last_activity for session: {}", token_key);
|
info!("Updated last_activity for session: {}", user_id);
|
||||||
} else {
|
} else {
|
||||||
info!("Session not found in Redis, proceeding with JWT-only data");
|
info!("Session not found in Redis, proceeding with JWT-only data");
|
||||||
}
|
}
|
||||||
@@ -160,10 +168,18 @@ pub async fn get_user_by_token(
|
|||||||
|
|
||||||
/// Сохраняет имя файла в Redis для пользователя
|
/// Сохраняет имя файла в Redis для пользователя
|
||||||
pub async fn user_added_file(
|
pub async fn user_added_file(
|
||||||
redis: &mut MultiplexedConnection,
|
redis: Option<&mut MultiplexedConnection>,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
) -> Result<(), actix_web::Error> {
|
) -> Result<(), actix_web::Error> {
|
||||||
|
let Some(redis) = redis else {
|
||||||
|
log::warn!(
|
||||||
|
"⚠️ Redis not available, skipping file tracking for user {}",
|
||||||
|
user_id
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
redis
|
redis
|
||||||
.sadd::<&str, &str, ()>(user_id, filename)
|
.sadd::<&str, &str, ()>(user_id, filename)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -131,6 +131,42 @@ pub fn create_file_response_with_analytics(
|
|||||||
.body(data)
|
.body(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Проверяет, является ли запрос от Vercel Edge API
|
||||||
|
pub fn is_vercel_request(req: &HttpRequest) -> bool {
|
||||||
|
// Проверяем User-Agent на Vercel
|
||||||
|
if let Some(user_agent) = req.headers().get("user-agent") {
|
||||||
|
if let Ok(ua_str) = user_agent.to_str() {
|
||||||
|
let ua_lower = ua_str.to_lowercase();
|
||||||
|
return ua_lower.contains("vercel") || ua_lower.contains("edge");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем Origin на Vercel домены
|
||||||
|
if let Some(origin) = req.headers().get("origin") {
|
||||||
|
if let Ok(origin_str) = origin.to_str() {
|
||||||
|
return origin_str.contains("vercel.app");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vercel-совместимый ответ для оптимизации edge caching
|
||||||
|
pub fn create_vercel_compatible_response(
|
||||||
|
content_type: &str,
|
||||||
|
data: Vec<u8>,
|
||||||
|
etag: &str,
|
||||||
|
) -> HttpResponse {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(content_type)
|
||||||
|
.insert_header(("etag", etag))
|
||||||
|
.insert_header(("cache-control", CACHE_CONTROL_VERCEL))
|
||||||
|
.insert_header(("access-control-allow-origin", "*"))
|
||||||
|
.insert_header(("x-vercel-cache", "HIT")) // для оптимизации Vercel
|
||||||
|
.insert_header(("x-content-type-options", "nosniff"))
|
||||||
|
.body(data)
|
||||||
|
}
|
||||||
|
|
||||||
// Removed complex ETag caching - Vercel handles caching on their edge
|
// Removed complex ETag caching - Vercel handles caching on their edge
|
||||||
|
|
||||||
/// Log request analytics for CORS whitelist tuning
|
/// Log request analytics for CORS whitelist tuning
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ use actix_web::error::ErrorNotFound;
|
|||||||
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError, web};
|
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError, web};
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
|
|
||||||
use super::common::create_file_response_with_analytics;
|
use super::common::{
|
||||||
|
create_file_response_with_analytics, create_vercel_compatible_response, is_vercel_request,
|
||||||
|
};
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::handlers::serve_file::serve_file;
|
use crate::handlers::serve_file::serve_file;
|
||||||
use crate::lookup::{find_file_by_pattern, get_mime_type};
|
use crate::lookup::{find_file_by_pattern, get_mime_type};
|
||||||
@@ -42,24 +44,21 @@ pub async fn proxy_handler(
|
|||||||
// Caching handled by Vercel Edge - focus on fast file serving
|
// Caching handled by Vercel Edge - focus on fast file serving
|
||||||
let content_type = match get_mime_type(&ext) {
|
let content_type = match get_mime_type(&ext) {
|
||||||
Some(mime) => mime.to_string(),
|
Some(mime) => mime.to_string(),
|
||||||
None => {
|
None => match find_file_by_pattern(None, &base_filename).await {
|
||||||
let mut redis = state.redis.clone();
|
Ok(Some(found_file)) => {
|
||||||
match find_file_by_pattern(&mut redis, &base_filename).await {
|
if let Some(found_ext) = found_file.split('.').next_back() {
|
||||||
Ok(Some(found_file)) => {
|
get_mime_type(found_ext)
|
||||||
if let Some(found_ext) = found_file.split('.').next_back() {
|
.unwrap_or("application/octet-stream")
|
||||||
get_mime_type(found_ext)
|
.to_string()
|
||||||
.unwrap_or("application/octet-stream")
|
} else {
|
||||||
.to_string()
|
"application/octet-stream".to_string()
|
||||||
} else {
|
|
||||||
"application/octet-stream".to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
error!("Unsupported file format for: {}", base_filename);
|
|
||||||
return Err(ErrorInternalServerError("Unsupported file format"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
_ => {
|
||||||
|
error!("Unsupported file format for: {}", base_filename);
|
||||||
|
return Err(ErrorInternalServerError("Unsupported file format"));
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
info!("Content-Type: {}", content_type);
|
info!("Content-Type: {}", content_type);
|
||||||
@@ -129,12 +128,23 @@ pub async fn proxy_handler(
|
|||||||
|
|
||||||
let elapsed = start_time.elapsed();
|
let elapsed = start_time.elapsed();
|
||||||
info!("File served from AWS in {:?}: {}", elapsed, path);
|
info!("File served from AWS in {:?}: {}", elapsed, path);
|
||||||
return Ok(create_file_response_with_analytics(
|
|
||||||
&content_type,
|
// Используем Vercel-совместимый ответ для Vercel запросов
|
||||||
filedata,
|
if is_vercel_request(&req) {
|
||||||
&req,
|
let etag = format!("\"{:x}\"", md5::compute(&filedata));
|
||||||
&path,
|
return Ok(create_vercel_compatible_response(
|
||||||
));
|
&content_type,
|
||||||
|
filedata,
|
||||||
|
&etag,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
return Ok(create_file_response_with_analytics(
|
||||||
|
&content_type,
|
||||||
|
filedata,
|
||||||
|
&req,
|
||||||
|
&path,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Failed to load from AWS path {}: {:?}", path, err);
|
warn!("Failed to load from AWS path {}: {:?}", path, err);
|
||||||
@@ -216,12 +226,23 @@ pub async fn proxy_handler(
|
|||||||
}
|
}
|
||||||
let elapsed = start_time.elapsed();
|
let elapsed = start_time.elapsed();
|
||||||
info!("File served from AWS in {:?}: {}", elapsed, filepath);
|
info!("File served from AWS in {:?}: {}", elapsed, filepath);
|
||||||
Ok(create_file_response_with_analytics(
|
|
||||||
&content_type,
|
// Используем Vercel-совместимый ответ для Vercel запросов
|
||||||
filedata,
|
if is_vercel_request(&req) {
|
||||||
&req,
|
let etag = format!("\"{:x}\"", md5::compute(&filedata));
|
||||||
&filepath,
|
Ok(create_vercel_compatible_response(
|
||||||
))
|
&content_type,
|
||||||
|
filedata,
|
||||||
|
&etag,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(create_file_response_with_analytics(
|
||||||
|
&content_type,
|
||||||
|
filedata,
|
||||||
|
&req,
|
||||||
|
&filepath,
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to download from AWS: {} - Error: {}", filepath, e);
|
error!("Failed to download from AWS: {} - Error: {}", filepath, e);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError};
|
use actix_web::{HttpRequest, HttpResponse, Result, error::ErrorInternalServerError};
|
||||||
use mime_guess::MimeGuess;
|
use mime_guess::MimeGuess;
|
||||||
|
|
||||||
use super::common::create_file_response_with_analytics;
|
use super::common::{
|
||||||
|
create_file_response_with_analytics, create_vercel_compatible_response, is_vercel_request,
|
||||||
|
};
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::s3_utils::{check_file_exists, load_file_from_s3};
|
use crate::s3_utils::{check_file_exists, load_file_from_s3};
|
||||||
|
|
||||||
@@ -35,11 +37,20 @@ pub async fn serve_file(
|
|||||||
// Определяем MIME тип
|
// Определяем MIME тип
|
||||||
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
|
let mime_type = MimeGuess::from_path(filepath).first_or_octet_stream();
|
||||||
|
|
||||||
// Создаем ответ с аналитикой
|
// Создаем ответ с аналитикой или Vercel-совместимый ответ
|
||||||
Ok(create_file_response_with_analytics(
|
if is_vercel_request(req) {
|
||||||
mime_type.as_ref(),
|
let etag = format!("\"{:x}\"", md5::compute(&filedata));
|
||||||
filedata,
|
Ok(create_vercel_compatible_response(
|
||||||
req,
|
mime_type.as_ref(),
|
||||||
filepath,
|
filedata,
|
||||||
))
|
&etag,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(create_file_response_with_analytics(
|
||||||
|
mime_type.as_ref(),
|
||||||
|
filedata,
|
||||||
|
req,
|
||||||
|
filepath,
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ pub async fn universal_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
match method.as_str() {
|
match method.as_str() {
|
||||||
"GET" => handle_get(req, state, &path).await,
|
"GET" => handle_get(req, state, &path).await,
|
||||||
"POST" => handle_post(req, payload, state, &path).await,
|
"POST" => handle_post(req, payload, state, &path).await,
|
||||||
@@ -55,14 +53,17 @@ async fn handle_get(
|
|||||||
state: web::Data<AppState>,
|
state: web::Data<AppState>,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<HttpResponse, actix_web::Error> {
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
if path == "/" || path.is_empty() {
|
match path {
|
||||||
// GET / - получение информации о пользователе
|
"/" | "" => {
|
||||||
crate::handlers::user::get_current_user_handler(req, state).await
|
// GET / - получение информации о пользователе
|
||||||
} else {
|
crate::handlers::user::get_current_user_handler(req, state).await
|
||||||
// GET /{path} - получение файла через proxy
|
}
|
||||||
let path_without_slash = path.trim_start_matches('/');
|
_ => {
|
||||||
let requested_res = web::Path::from(path_without_slash.to_string());
|
// GET /{path} - получение файла через proxy
|
||||||
crate::handlers::proxy::proxy_handler(req, requested_res, state).await
|
let path_without_slash = path.trim_start_matches('/');
|
||||||
|
let requested_res = web::Path::from(path_without_slash.to_string());
|
||||||
|
crate::handlers::proxy::proxy_handler(req, requested_res, state).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,13 +154,12 @@ pub async fn upload_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем информацию о файле в Redis
|
// Сохраняем информацию о файле в Redis
|
||||||
let mut redis = state.redis.clone();
|
if let Err(e) = store_file_info(None, &filename, &content_type).await {
|
||||||
if let Err(e) = store_file_info(&mut redis, &filename, &content_type).await {
|
|
||||||
error!("Failed to store file info in Redis: {}", e);
|
error!("Failed to store file info in Redis: {}", e);
|
||||||
// Не прерываем процесс, файл уже загружен в S3
|
// Не прерываем процесс, файл уже загружен в S3
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = user_added_file(&mut redis, &user_id, &filename).await {
|
if let Err(e) = user_added_file(None, &user_id, &filename).await {
|
||||||
error!("Failed to record user file association: {}", e);
|
error!("Failed to record user file association: {}", e);
|
||||||
// Не прерываем процесс
|
// Не прерываем процесс
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ pub async fn get_current_user_handler(
|
|||||||
info!("Getting user info for valid token");
|
info!("Getting user info for valid token");
|
||||||
|
|
||||||
// Получаем информацию о пользователе из Redis сессии
|
// Получаем информацию о пользователе из Redis сессии
|
||||||
let mut redis = state.redis.clone();
|
let user = match get_user_by_token(token, None, state.request_timeout).await {
|
||||||
let user = match get_user_by_token(token, &mut redis, state.request_timeout).await {
|
|
||||||
Ok(user) => {
|
Ok(user) => {
|
||||||
info!(
|
info!(
|
||||||
"Successfully retrieved user info: user_id={}, username={:?}",
|
"Successfully retrieved user info: user_id={}, username={:?}",
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ pub fn get_mime_type(extension: &str) -> Option<&'static str> {
|
|||||||
|
|
||||||
/// Ищет файл в Redis по шаблону имени
|
/// Ищет файл в Redis по шаблону имени
|
||||||
pub async fn find_file_by_pattern(
|
pub async fn find_file_by_pattern(
|
||||||
redis: &mut MultiplexedConnection,
|
redis: Option<&mut MultiplexedConnection>,
|
||||||
pattern: &str,
|
pattern: &str,
|
||||||
) -> Result<Option<String>, actix_web::Error> {
|
) -> Result<Option<String>, actix_web::Error> {
|
||||||
|
let Some(redis) = redis else {
|
||||||
|
log::warn!("⚠️ Redis not available, returning None for pattern search");
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
let pattern_key = format!("files:*{}*", pattern);
|
let pattern_key = format!("files:*{}*", pattern);
|
||||||
let files: Vec<String> = redis
|
let files: Vec<String> = redis
|
||||||
.keys(&pattern_key)
|
.keys(&pattern_key)
|
||||||
@@ -56,10 +61,18 @@ pub async fn find_file_by_pattern(
|
|||||||
|
|
||||||
/// Сохраняет файл в Redis с его MIME-типом
|
/// Сохраняет файл в Redis с его MIME-типом
|
||||||
pub async fn store_file_info(
|
pub async fn store_file_info(
|
||||||
redis: &mut MultiplexedConnection,
|
redis: Option<&mut MultiplexedConnection>,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
mime_type: &str,
|
mime_type: &str,
|
||||||
) -> Result<(), actix_web::Error> {
|
) -> Result<(), actix_web::Error> {
|
||||||
|
let Some(redis) = redis else {
|
||||||
|
log::warn!(
|
||||||
|
"⚠️ Redis not available, skipping file info storage for {}",
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
let file_key = format!("files:{}", filename);
|
let file_key = format!("files:{}", filename);
|
||||||
redis
|
redis
|
||||||
.set::<_, _, ()>(&file_key, mime_type)
|
.set::<_, _, ()>(&file_key, mime_type)
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.allowed_origin("https://new.discours.io")
|
.allowed_origin("https://new.discours.io")
|
||||||
.allowed_origin("https://testing.discours.io")
|
.allowed_origin("https://testing.discours.io")
|
||||||
.allowed_origin("https://testing3.discours.io")
|
.allowed_origin("https://testing3.discours.io")
|
||||||
|
.allowed_origin("https://vercel.app") // для Vercel edge functions
|
||||||
|
.allowed_origin("https://*.vercel.app") // для Vercel preview deployments
|
||||||
.allowed_origin("http://localhost:3000") // для разработки
|
.allowed_origin("http://localhost:3000") // для разработки
|
||||||
.allowed_methods(vec!["GET", "POST", "OPTIONS"])
|
.allowed_methods(vec!["GET", "POST", "OPTIONS"])
|
||||||
.allowed_headers(vec![
|
.allowed_headers(vec![
|
||||||
@@ -85,7 +87,6 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.app_data(web::Data::new(app_state.clone()))
|
.app_data(web::Data::new(app_state.clone()))
|
||||||
.app_data(web::PayloadConfig::new(security_config.max_payload_size))
|
.app_data(web::PayloadConfig::new(security_config.max_payload_size))
|
||||||
.app_data(web::JsonConfig::default().limit(1024 * 1024)) // 1MB для JSON
|
.app_data(web::JsonConfig::default().limit(1024 * 1024)) // 1MB для JSON
|
||||||
|
|
||||||
.wrap(security_headers)
|
.wrap(security_headers)
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.wrap(Logger::default())
|
.wrap(Logger::default())
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ impl Default for SecurityConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_payload_size: 500 * 1024 * 1024, // 500MB
|
max_payload_size: 500 * 1024 * 1024, // 500MB
|
||||||
request_timeout_seconds: 300, // 5 минут
|
request_timeout_seconds: 300, // 5 минут
|
||||||
max_path_length: 1000,
|
max_path_length: 1000,
|
||||||
max_headers_count: 50,
|
max_headers_count: 50,
|
||||||
max_header_value_length: 8192,
|
max_header_value_length: 8192,
|
||||||
@@ -51,7 +51,7 @@ impl SecurityConfig {
|
|||||||
/// Валидирует запрос на базовые параметры безопасности
|
/// Валидирует запрос на базовые параметры безопасности
|
||||||
pub fn validate_request(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
|
pub fn validate_request(&self, req: &HttpRequest) -> Result<(), actix_web::Error> {
|
||||||
let path = req.path();
|
let path = req.path();
|
||||||
|
|
||||||
// Проверка длины пути
|
// Проверка длины пути
|
||||||
if path.len() > self.max_path_length {
|
if path.len() > self.max_path_length {
|
||||||
warn!("Path too long: {} chars", path.len());
|
warn!("Path too long: {} chars", path.len());
|
||||||
@@ -79,7 +79,8 @@ impl SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Проверка на подозрительные символы в пути
|
// Проверка на подозрительные символы в пути
|
||||||
if path.contains("..") || path.contains('\0') || path.contains('\r') || path.contains('\n') {
|
if path.contains("..") || path.contains('\0') || path.contains('\r') || path.contains('\n')
|
||||||
|
{
|
||||||
warn!("Suspicious characters in path: {}", path);
|
warn!("Suspicious characters in path: {}", path);
|
||||||
return Err(actix_web::error::ErrorBadRequest(
|
return Err(actix_web::error::ErrorBadRequest(
|
||||||
"Invalid characters in path",
|
"Invalid characters in path",
|
||||||
@@ -98,7 +99,7 @@ impl SecurityConfig {
|
|||||||
pub fn check_suspicious_patterns(&self, path: &str) -> bool {
|
pub fn check_suspicious_patterns(&self, path: &str) -> bool {
|
||||||
let suspicious_patterns = [
|
let suspicious_patterns = [
|
||||||
"/admin",
|
"/admin",
|
||||||
"/wp-admin",
|
"/wp-admin",
|
||||||
"/phpmyadmin",
|
"/phpmyadmin",
|
||||||
"/.env",
|
"/.env",
|
||||||
"/config",
|
"/config",
|
||||||
@@ -136,26 +137,34 @@ impl SecurityConfig {
|
|||||||
.as_secs();
|
.as_secs();
|
||||||
|
|
||||||
let mut counts = self.upload_protection.upload_counts.write().await;
|
let mut counts = self.upload_protection.upload_counts.write().await;
|
||||||
|
|
||||||
// Очищаем старые записи (старше минуты)
|
// Очищаем старые записи (старше минуты)
|
||||||
counts.retain(|_, (_, timestamp)| current_time - *timestamp < 60);
|
counts.retain(|_, (_, timestamp)| current_time - *timestamp < 60);
|
||||||
|
|
||||||
// Проверяем текущий IP
|
// Проверяем текущий IP
|
||||||
let current_count = counts.get(ip).map(|(count, _)| *count).unwrap_or(0);
|
let current_count = counts.get(ip).map(|(count, _)| *count).unwrap_or(0);
|
||||||
let first_upload_time = counts.get(ip).map(|(_, time)| *time).unwrap_or(current_time);
|
let first_upload_time = counts
|
||||||
|
.get(ip)
|
||||||
|
.map(|(_, time)| *time)
|
||||||
|
.unwrap_or(current_time);
|
||||||
|
|
||||||
if current_time - first_upload_time < 60 {
|
if current_time - first_upload_time < 60 {
|
||||||
// В пределах минуты
|
// В пределах минуты
|
||||||
if current_count >= self.upload_protection.max_uploads_per_minute {
|
if current_count >= self.upload_protection.max_uploads_per_minute {
|
||||||
warn!("Upload limit exceeded for IP {}: {} uploads in minute", ip, current_count);
|
warn!(
|
||||||
return Err(actix_web::error::ErrorTooManyRequests("Upload limit exceeded"));
|
"Upload limit exceeded for IP {}: {} uploads in minute",
|
||||||
|
ip, current_count
|
||||||
|
);
|
||||||
|
return Err(actix_web::error::ErrorTooManyRequests(
|
||||||
|
"Upload limit exceeded",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
counts.insert(ip.to_string(), (current_count + 1, first_upload_time));
|
counts.insert(ip.to_string(), (current_count + 1, first_upload_time));
|
||||||
} else {
|
} else {
|
||||||
// Новая минута, сбрасываем счетчик
|
// Новая минута, сбрасываем счетчик
|
||||||
counts.insert(ip.to_string(), (1, current_time));
|
counts.insert(ip.to_string(), (1, current_time));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,16 +178,18 @@ impl SecurityConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем X-Real-IP
|
// Проверяем X-Real-IP
|
||||||
if let Some(real_ip) = req.headers().get("x-real-ip") {
|
if let Some(real_ip) = req.headers().get("x-real-ip") {
|
||||||
if let Ok(real_ip_str) = real_ip.to_str() {
|
if let Ok(real_ip_str) = real_ip.to_str() {
|
||||||
return real_ip_str.to_string();
|
return real_ip_str.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback на connection info
|
// Fallback на connection info
|
||||||
req.connection_info().peer_addr().unwrap_or("unknown").to_string()
|
req.connection_info()
|
||||||
|
.peer_addr()
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,5 @@
|
|||||||
use actix_web::{App, HttpResponse, test, web};
|
use actix_web::{App, HttpResponse, test, web};
|
||||||
|
|
||||||
/// Простой тест health check
|
|
||||||
#[actix_web::test]
|
|
||||||
async fn test_health_check() {
|
|
||||||
let app = test::init_service(App::new().route(
|
|
||||||
"/",
|
|
||||||
web::get().to(|req: actix_web::HttpRequest| async move {
|
|
||||||
match req.method().as_str() {
|
|
||||||
"GET" => Ok::<HttpResponse, actix_web::Error>(
|
|
||||||
HttpResponse::Ok().content_type("text/plain").body("ok"),
|
|
||||||
),
|
|
||||||
_ => {
|
|
||||||
Ok::<HttpResponse, actix_web::Error>(HttpResponse::MethodNotAllowed().finish())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Тестируем GET запрос
|
|
||||||
let req = test::TestRequest::get().uri("/").to_request();
|
|
||||||
|
|
||||||
let resp = test::call_service(&app, req).await;
|
|
||||||
assert!(resp.status().is_success());
|
|
||||||
|
|
||||||
let body = test::read_body(resp).await;
|
|
||||||
assert_eq!(body, "ok");
|
|
||||||
|
|
||||||
// Тестируем POST запрос (должен вернуть 405)
|
|
||||||
let req = test::TestRequest::post().uri("/").to_request();
|
|
||||||
|
|
||||||
let resp = test::call_service(&app, req).await;
|
|
||||||
assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Тест для проверки JSON сериализации/десериализации
|
/// Тест для проверки JSON сериализации/десериализации
|
||||||
#[test]
|
#[test]
|
||||||
async fn test_json_serialization() {
|
async fn test_json_serialization() {
|
||||||
|
|||||||
Reference in New Issue
Block a user