main
UI Deploy - indir.bilgich.com 🎨 / build-and-deploy (push) Successful in 4m8s

This commit is contained in:
2026-03-06 15:44:44 +03:00
parent 3aa07d096f
commit ce7702b1cb
43 changed files with 4279 additions and 78 deletions
Vendored
BIN
View File
Binary file not shown.
+7 -7
View File
@@ -1,5 +1,5 @@
name: UI Deploy (Next-Auth Support) 🎨 name: UI Deploy - indir.bilgich.com 🎨
run-name: ${{ gitea.actor }} frontend güncelliyor... run-name: ${{ gitea.actor }} indir-fe güncelliyor...
on: on:
push: push:
@@ -19,19 +19,19 @@ jobs:
--build-arg NEXT_PUBLIC_API_URL='${{ secrets.NEXT_PUBLIC_API_URL }}' \ --build-arg NEXT_PUBLIC_API_URL='${{ secrets.NEXT_PUBLIC_API_URL }}' \
--build-arg NEXT_PUBLIC_AUTH_REQUIRED='${{ secrets.NEXT_PUBLIC_AUTH_REQUIRED }}' \ --build-arg NEXT_PUBLIC_AUTH_REQUIRED='${{ secrets.NEXT_PUBLIC_AUTH_REQUIRED }}' \
--build-arg NEXT_PUBLIC_GOOGLE_API_KEY='${{ secrets.NEXT_PUBLIC_GOOGLE_API_KEY }}' \ --build-arg NEXT_PUBLIC_GOOGLE_API_KEY='${{ secrets.NEXT_PUBLIC_GOOGLE_API_KEY }}' \
-t frontend-proje:latest . -t ui-indir:latest .
- name: Eski Konteyneri Sil - name: Eski Konteyneri Sil
run: docker rm -f frontend-container || true run: docker rm -f ui-indir-container || true
- name: Yeni Versiyonu Başlat - name: Yeni Versiyonu Başlat
# Sunucu tarafında (Server-side/Auth) lazım olanları run anında veriyoruz # Sunucu tarafında (Server-side/Auth) lazım olanları run anında veriyoruz
run: | run: |
docker run -d \ docker run -d \
--name frontend-container \ --name ui-indir-container \
--restart always \ --restart always \
--network gitea-server_gitea \ --network gitea-server_gitea \
-p 1800:3000 \ -p 1507:3000 \
-e NEXTAUTH_SECRET='${{ secrets.NEXTAUTH_SECRET }}' \ -e NEXTAUTH_SECRET='${{ secrets.NEXTAUTH_SECRET }}' \
-e NEXTAUTH_URL='${{ secrets.NEXTAUTH_URL }}' \ -e NEXTAUTH_URL='${{ secrets.NEXTAUTH_URL }}' \
frontend-proje:latest ui-indir:latest
+6
View File
@@ -0,0 +1,6 @@
{
"i18n-ally.localesPaths": [
"messages",
"src/i18n"
]
}
+169
View File
@@ -0,0 +1,169 @@
# AI Session Log - 2026-03-05
## Overview
Bu oturumda sosyal medya video indirici özelliği tasarlandı ve implemente edildi.
## 1. Mimari Planlama
**İstek:** Sosyal medya platformlarından (YouTube, Instagram, TikTok, X, Facebook) video indirme özelliği.
**Kararlar:**
- Next.js API Routes kullanıldı (backend ayrı servis gereksinimi yok)
- ~~Cobalt API (public instance) entegrasyonu~~ → Cobalt API v7 Kasım 2024'te kapatıldı
- **ab-downloader npm paketi** entegre edildi (kullanıcının isteği üzerine)
- Auth sistemi gereksiz (basit, herkes kullanabilir)
- IP-based rate limiting (abuse önleme)
**Plan Dosyası:** `plans/social-media-downloader-plan.md`
## 2. Oluşturulan Dosyalar
### Types
- `src/types/download.ts` - TypeScript tip tanımları
- `src/types/ab-downloader.d.ts` - ab-downloader için type declarations
### Backend (API Routes)
- `src/app/api/download/route.ts` - Ana download endpoint
### Utilities
- `src/lib/download/platform-detector.ts` - URL'den platform tespiti
- `src/lib/download/downloader.ts` - ab-downloader wrapper (platform-specific fonksiyonlar)
- `src/lib/security/url-validator.ts` - URL validation ve sanitization
- `src/lib/security/rate-limiter.ts` - IP-based rate limiting
### Frontend Components
- `src/components/site/download/download-form.tsx` - URL input form
- `src/components/site/download/download-result.tsx` - Sonuç görüntüleme
- `src/app/[locale]/page.tsx` - Ana sayfa (download formu içeriyor)
### i18n
- `messages/en.json` - İngilizce çeviriler (download bölümü)
- `messages/tr.json` - Türkçe çeviriler (download bölümü)
### Navigation
- `src/config/navigation.ts` - Basitleştirildi (sadece home linki)
- `src/proxy.ts` - Auth gereksinimi kaldırıldı (herkes erişebilir)
## 3. ab-downloader Entegrasyonu
### Paket Bilgisi
- **Paket:** `ab-downloader`
- **Kaynak:** https://www.npmjs.com/package/ab-downloader
- **Versiyon:** 5.0.0
### Desteklenen Platformlar
| Platform | Fonksiyon | Notlar |
|----------|-----------|--------|
| Instagram | `igdl()` | Array döndürür |
| YouTube | `youtube()` | - |
| TikTok | `ttdl()` | - |
| Twitter/X | `twitter()` | - |
| Facebook | `fbdown()` | - |
| Generic | `aio()` | Auto-detect |
### Response Format
```typescript
interface DownloadResult {
url: string; // Direct download URL
title?: string; // Video title
thumbnail?: string; // Thumbnail URL
developer?: string; // "AbroCodes"
contactme?: string; // "Telegram: abrocodes"
}
```
### Örnek Kullanım
```typescript
import { igdl } from 'ab-downloader';
const result = await igdl('https://www.instagram.com/reel/xxx/');
// result bir array: [{ url, thumbnail, ... }]
const downloadUrl = result[0].url;
```
## 4. API Endpoint
### POST /api/download
**Request:**
```json
{
"url": "https://www.instagram.com/reel/xxx/"
}
```
**Response (Success):**
```json
{
"success": true,
"data": {
"downloadUrl": "https://d.rapidcdn.app/...",
"filename": "instagram_1772720934409.mp4",
"platform": "instagram",
"thumbnail": "https://d.rapidcdn.app/thumb?..."
}
}
```
**Response (Error):**
```json
{
"success": false,
"error": {
"code": "error.download.failed",
"message": "Video indirilemedi"
}
}
```
## 5. Güvenlik Önlemleri
- **URL Validation:** Sadece desteklenen platformlardan URL'lere izin verilir
- **Rate Limiting:** IP başına 10 istek/saat limiti
- **HTTPS Only:** Sadece HTTPS URL'ler kabul edilir
- **Request Timeout:** 30 saniye timeout
## 6. Test Sonuçları
### Instagram Test
```bash
curl -X POST http://localhost:3001/api/download \
-H "Content-Type: application/json" \
-d '{"url":"https://www.instagram.com/reel/DVejgkGkUPU/"}'
```
**Sonuç:** ✅ Başarılı - Download URL ve thumbnail döndü
## 7. Önemli Notlar
1. **Cobalt API Kapatıldı:** Cobalt API v7 Kasım 2024'te kapatıldığı için `ab-downloader` paketine geçildi
2. **Array Response:** `igdl()` fonksiyonu array döndürüyor, bu yüzden `getFirstResult()` helper fonksiyonu kullanılıyor
3. **Type Declarations:** Paket TypeScript support içermiyor, bu yüzden `src/types/ab-downloader.d.ts` oluşturuldu
## 8. Gelecek İyileştirmeler
1. Video kalite seçimi
2. Sadece ses indirme seçeneği
3. Download progress indicator
4. Video thumbnail gösterimi (önbellek ile)
5. Çoklu dil desteği genişletme
## Summary
Sosyal medya indirici özelliği başarıyla implemente edildi. Cobalt API'nin kapatılması nedeniyle `ab-downloader` npm paketi kullanıldı. Instagram videosu başarıyla indirildi ve test edildi. Tüm kod TypeScript ile strict modda yazıldı ve projenin mevcut mimarisine uygun şekilde geliştirildi.
+25 -2
View File
@@ -28,5 +28,28 @@
"name": "Name", "name": "Name",
"low": "Low", "low": "Low",
"medium": "Medium", "medium": "Medium",
"high": "High" "high": "High",
} "download": {
"title": "Video Downloader",
"subtitle": "Download videos from YouTube, Instagram, TikTok, X, and Facebook",
"placeholder": "Paste video URL here...",
"download": "Download",
"downloading": "Downloading...",
"error": "Download Failed",
"unknownError": "An unknown error occurred",
"readyToDownload": "Ready to Download",
"downloadFile": "Download File",
"newDownload": "New Download",
"downloadHint": "Click the button to start downloading. The file will be saved to your downloads folder.",
"supportedPlatforms": "Supported Platforms",
"disclaimer": "Please respect copyright laws. Only download content you have permission to use.",
"errors": {
"urlRequired": "Please enter a URL",
"invalidUrl": "Please enter a valid URL",
"unsupportedPlatform": "This platform is not supported",
"downloadFailed": "Download Failed",
"networkError": "Network Error"
}
},
"predictions": "Predictions"
}
+25 -2
View File
@@ -28,5 +28,28 @@
"name": "İsim", "name": "İsim",
"low": "Düşük", "low": "Düşük",
"medium": "Orta", "medium": "Orta",
"high": "Yüksek" "high": "Yüksek",
} "download": {
"title": "Video İndirici",
"subtitle": "YouTube, Instagram, TikTok, X ve Facebook videolarını indirin",
"placeholder": "Video URL'sini buraya yapıştırın...",
"download": "İndir",
"downloading": "İndiriliyor...",
"error": "İndirme Başarısız",
"unknownError": "Bilinmeyen bir hata oluştu",
"readyToDownload": "İndirmeye Hazır",
"downloadFile": "Dosyayı İndir",
"newDownload": "Yeni İndirme",
"downloadHint": "İndirmeyi başlatmak için butona tıklayın. Dosya indirme klasörünüze kaydedilecek.",
"supportedPlatforms": "Desteklenen Platformlar",
"disclaimer": "Lütfen telif haklarına saygı duyulması gerektiğini unutmayın. Sadece izniniz olan içerikleri indirin.",
"errors": {
"urlRequired": "Lütfen bir URL girin",
"invalidUrl": "Lütfen geçerli bir URL girin",
"unsupportedPlatform": "Bu platform desteklenmiyor",
"downloadFailed": "İndirme Başarısız",
"networkError": "Ağ Hatası"
}
},
"predictions": "Tahminler"
}
+1
View File
@@ -3,6 +3,7 @@ import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
serverExternalPackages: ["cheerio", "@vreden/youtube_scraper", "yt-search"],
experimental: { experimental: {
optimizePackageImports: ["@chakra-ui/react"], optimizePackageImports: ["@chakra-ui/react"],
}, },
+175
View File
@@ -0,0 +1,175 @@
{
"project": "OpenClaw - Kişisel AI Asistanı",
"date": "2026-03-05",
"server": {
"hostname": "Raspberry Pi",
"ip": "95.70.252.214",
"ssh_port": 2222,
"ssh_user": "haruncan",
"ssh_command": "ssh -p 2222 haruncan@95.70.252.214"
},
"storage": {
"sd_card": {
"mount": "/",
"size": "57GB",
"used": "25GB",
"available": "30GB",
"usage_percent": "46%"
},
"external_disk": {
"mount": "/mnt/extreme",
"device": "/dev/sda1",
"filesystem": "ext4",
"size": "916GB",
"used": "15GB",
"available": "856GB",
"usage_percent": "2%"
}
},
"services": {
"openclaw": {
"name": "Open WebUI",
"container_name": "openclaw",
"image": "ghcr.io/open-webui/open-webui:main",
"port": 3001,
"status": "running",
"url_local": "http://127.0.0.1:3001",
"url_domain": "http://ai.bilgich.com",
"description": "ChatGPT benzeri web arayüzü"
},
"ollama": {
"port": 11434,
"api_url": "http://127.0.0.1:11434",
"models": [
"llama3.2:3b"
],
"models_path": "/mnt/extreme/ollama",
"status": "running"
},
"gitea": {
"url": "http://gitea.bilgich.com",
"port": 1224,
"ssh_port": 222,
"status": "running"
},
"portainer": {
"url": "http://95.70.252.214:9000",
"port": 9000,
"description": "Docker yönetim arayüzü"
},
"postgres": {
"gitea_db": "postgres:14",
"backend_db": "postgres:16-alpine"
},
"redis": {
"container": "apps_redis",
"image": "redis:alpine"
}
},
"nginx": {
"config_path": "/etc/nginx/sites-available/openclaw.conf",
"server_name": "ai.bilgich.com",
"proxy_pass": "http://127.0.0.1:3001",
"ssl": false,
"ssl_note": "DNS kaydı eksik olduğu için Let's Encrypt sertifikası alınamadı"
},
"docker_containers": [
{
"name": "openclaw",
"status": "Up 27 minutes (healthy)",
"ports": "3001:8080"
},
{
"name": "gitea",
"status": "Up 42 minutes",
"ports": "222:22, 1224:3000"
},
{
"name": "gitea_db",
"status": "Up 42 minutes",
"ports": "5432"
},
{
"name": "backend_db",
"status": "Up 42 minutes",
"ports": "5432"
},
{
"name": "apps_redis",
"status": "Up 42 minutes",
"ports": "6379"
},
{
"name": "portainer",
"status": "Up 42 minutes",
"ports": "9000:9000"
},
{
"name": "gitea_runner",
"status": "Up About an hour",
"ports": "-"
},
{
"name": "ui-skript-container",
"status": "Up About an hour",
"ports": "1506:3000"
},
{
"name": "backend-skript-container",
"status": "Up About an hour",
"ports": "1806:3000"
},
{
"name": "backend-digicraft-container",
"status": "Up About an hour",
"ports": "1805:3001"
},
{
"name": "ui-digicraft-container",
"status": "Up About an hour",
"ports": "1505:80"
},
{
"name": "frontend-container",
"status": "Up About an hour",
"ports": "1800:3000"
}
],
"data_paths": {
"docker_data": "/mnt/extreme/docker-data",
"docker_config": "/mnt/extreme/docker",
"projects": "/mnt/extreme/projects",
"ollama": "/mnt/extreme/ollama",
"openclaw_data": "/mnt/extreme/open-webui/data"
},
"pending_tasks": {
"dns_record": {
"description": "ai.bilgich.com için DNS A kaydı eklenmeli",
"type": "A",
"name": "ai",
"value": "95.70.252.214"
},
"ssl_certificate": {
"description": "DNS kaydı eklendikten sonra Let's Encrypt sertifikası alınmalı",
"command": "sudo certbot --nginx -d ai.bilgich.com"
}
},
"useful_commands": {
"openclaw_logs": "docker logs openclaw --tail 50",
"openclaw_restart": "docker restart openclaw",
"ollama_models": "ollama list",
"ollama_pull_model": "ollama pull <model_name>",
"docker_ps": "docker ps",
"nginx_test": "sudo nginx -t",
"nginx_reload": "sudo systemctl reload nginx",
"system_resources": "free -h && df -h"
},
"first_time_setup": {
"step1": "DNS A kaydı ekle: ai -> 95.70.252.214",
"step2": "DNS propagation bekle (5-10 dakika)",
"step3": "http://ai.bilgich.com adresine git",
"step4": "İlk hesabı oluştur (admin)",
"step5": "Model otomatik llama3.2:3b olacak",
"step6": "Kullanıma hazır!"
}
}
+357 -20
View File
@@ -13,6 +13,7 @@
"@google/genai": "^1.35.0", "@google/genai": "^1.35.0",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.16",
"ab-downloader": "^1.0.2",
"axios": "^1.13.1", "axios": "^1.13.1",
"i18next": "^25.6.0", "i18next": "^25.6.0",
"next": "16.0.0", "next": "16.0.0",
@@ -20,10 +21,14 @@
"next-intl": "^4.4.0", "next-intl": "^4.4.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17", "nextjs-toploader": "^3.9.17",
"ofetch": "^1.5.1",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.65.0", "react-hook-form": "^7.65.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"snapsave-media-downloader": "^2.4.0",
"test-downloader": "^1.0.5",
"undici": "^7.22.0",
"yup": "^1.7.1" "yup": "^1.7.1"
}, },
"devDependencies": { "devDependencies": {
@@ -146,7 +151,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.28.6", "@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6", "@babel/generator": "^7.28.6",
@@ -556,7 +560,6 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.31.0.tgz", "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.31.0.tgz",
"integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==", "integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@ark-ui/react": "^5.29.1", "@ark-ui/react": "^5.29.1",
"@emotion/is-prop-valid": "^1.4.0", "@emotion/is-prop-valid": "^1.4.0",
@@ -692,7 +695,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@@ -1986,7 +1988,6 @@
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==", "integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@swc/helpers": "^0.5.0" "@swc/helpers": "^0.5.0"
} }
@@ -2063,6 +2064,17 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@jsdoc/salty": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.10.tgz",
"integrity": "sha512-VFHSsQAQp8y1NJvAJBpLs9I2shHE6hz9TwukocDObuUgGVAq62yZGbTgJg04Z3Fj0XSMWe0sJqGg5dhKGTV92A==",
"dependencies": {
"lodash": "^4.17.23"
},
"engines": {
"node": ">=v12.0.0"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -2905,7 +2917,6 @@
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
@@ -3067,7 +3078,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -3127,7 +3137,6 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0", "@typescript-eslint/types": "8.54.0",
@@ -4549,13 +4558,21 @@
"integrity": "sha512-KLm0pmOtf4ydALbaVLboL7W98TDVxwVVLvSuvtRgV53XTjlsVopTRA5/Xmzq2NhWujDZAXv7bRV603NDgDcjSw==", "integrity": "sha512-KLm0pmOtf4ydALbaVLboL7W98TDVxwVVLvSuvtRgV53XTjlsVopTRA5/Xmzq2NhWujDZAXv7bRV603NDgDcjSw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/ab-downloader": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/ab-downloader/-/ab-downloader-1.0.2.tgz",
"integrity": "sha512-82wWQhWd2DhO3a7InQKUnv8/X7yUfCJJElYHEn8pTN6h6SX2hW1r3F43fgMyX9Se/x6ERGssOK23f6JRMiP2lQ==",
"dependencies": {
"axios": "^1.4.0",
"docdash": "^2.0.2"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4921,7 +4938,6 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/types": "^7.26.0" "@babel/types": "^7.26.0"
} }
@@ -4984,6 +5000,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -5028,7 +5049,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -5155,6 +5175,46 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -5301,6 +5361,32 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5452,6 +5538,11 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -5461,6 +5552,14 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/docdash": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/docdash/-/docdash-2.0.2.tgz",
"integrity": "sha512-3SDDheh9ddrwjzf6dPFe1a16M6ftstqTNjik2+1fx46l24H9dD2osT2q9y+nBEC1wWz4GIqA48JmicOLQ0R8xA==",
"dependencies": {
"@jsdoc/salty": "^0.2.1"
}
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -5474,6 +5573,57 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
]
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "17.2.3", "version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@@ -5529,6 +5679,29 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": { "node_modules/error-ex": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@@ -5781,7 +5954,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5983,7 +6155,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -6960,6 +7131,35 @@
"react-is": "^16.7.0" "react-is": "^16.7.0"
} }
}, },
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -7004,6 +7204,17 @@
} }
} }
}, },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -7745,6 +7956,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -7935,7 +8151,6 @@
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==", "integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@next/env": "16.0.0", "@next/env": "16.0.0",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
@@ -8146,6 +8361,11 @@
"url": "https://opencollective.com/node-fetch" "url": "https://opencollective.com/node-fetch"
} }
}, },
"node_modules/node-fetch-native": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -8169,6 +8389,17 @@
"integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/oauth": { "node_modules/oauth": {
"version": "0.9.15", "version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
@@ -8306,6 +8537,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/ofetch": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz",
"integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==",
"dependencies": {
"destr": "^2.0.5",
"node-fetch-native": "^1.6.7",
"ufo": "^1.6.1"
}
},
"node_modules/oidc-token-hash": { "node_modules/oidc-token-hash": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
@@ -8459,6 +8700,51 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -8599,7 +8885,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/preact" "url": "https://opencollective.com/preact"
@@ -8747,7 +9032,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -8757,7 +9041,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -8770,7 +9053,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -9045,6 +9327,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -9304,6 +9591,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/snapsave-media-downloader": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/snapsave-media-downloader/-/snapsave-media-downloader-2.4.0.tgz",
"integrity": "sha512-WSaC6rQ0SCCktbZ1i/UAZdZyzcULeq2jibp3YrU3W99rk4xh7c7apGVyzhNb9ksYOtkXZz/1A/5+mjChODGgmg==",
"dependencies": {
"cheerio": "^1.2.0",
"ofetch": "^1.5.1",
"undici": "^7.22.0"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.5.7", "version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -9629,6 +9926,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/test-downloader": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/test-downloader/-/test-downloader-1.0.5.tgz",
"integrity": "sha512-SQnzje3VKIobMfmlqenW2GQsskXmgwqceT1sbwW+/7ODCO7h0lkE+AsTy3PXfqOhGTlbWWMB8jIcGvT2KWM+7w==",
"dependencies": {
"axios": "^1.4.0",
"cheerio": "^1.0.0",
"docdash": "^2.0.2"
}
},
"node_modules/tiny-case": { "node_modules/tiny-case": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
@@ -9683,7 +9990,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -9864,7 +10170,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -9897,6 +10202,11 @@
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/ufo": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="
},
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -9916,6 +10226,14 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -10049,6 +10367,26 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"engines": {
"node": ">=18"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -10319,7 +10657,6 @@
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+6 -1
View File
@@ -14,6 +14,7 @@
"@google/genai": "^1.35.0", "@google/genai": "^1.35.0",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.16", "@tanstack/react-query": "^5.90.16",
"ab-downloader": "^1.0.2",
"axios": "^1.13.1", "axios": "^1.13.1",
"i18next": "^25.6.0", "i18next": "^25.6.0",
"next": "16.0.0", "next": "16.0.0",
@@ -21,10 +22,14 @@
"next-intl": "^4.4.0", "next-intl": "^4.4.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nextjs-toploader": "^3.9.17", "nextjs-toploader": "^3.9.17",
"ofetch": "^1.5.1",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.65.0", "react-hook-form": "^7.65.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"snapsave-media-downloader": "^2.4.0",
"test-downloader": "^1.0.5",
"undici": "^7.22.0",
"yup": "^1.7.1" "yup": "^1.7.1"
}, },
"devDependencies": { "devDependencies": {
@@ -41,4 +46,4 @@
"typescript": "^5" "typescript": "^5"
}, },
"description": "Generated by Frontend CLI" "description": "Generated by Frontend CLI"
} }
+8
View File
@@ -0,0 +1,8 @@
const url = "https://snapsave.app/render.php?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2aWRlb191cmwiOiJodHRwczovL3ZpZGVvLXpyaDEtMS54eC5mYmNkbi5uZXQvbzEvdi90Mi9mMi9tMzY2L0FRUEE1T3R4MWlRdFl6UzMwTUxhZFJ6ZzFxUkRtSS1DdDhLeHlGYXpaVC1QYjRhRVF1Q3U5eXozakIxSFBqTjg3eTVkX2VoMWd1ekZMVlo1RVZ2TDhFWHp6VzhRb1JQUU5Gd1BwMTgubXA0P19uY19jYXQ9MTA4Jl9uY19zaWQ9OWNhMDUyJl9uY19odD12aWRlby16cmgxLTEueHguZmJjZG4ubmV0Jl9uY19vaGM9NGtBSjhjMC04Qk1RN2tOdndFLXE2U1YmZWZnPWV5SjJaVzVqYjJSbFgzUmhaeUk2SW1SaGMyaGZjakpoZGpFdGNqRm5aVzR5ZG5BNVgzRTVNQ0lzSW5acFpHVnZYMmxrSWpveE5UWTRNakkyTURZM056TTJOelUxTENKdmFXeGZkWEpzWjJWdVgyRndjRjlwWkNJNk1Dd2lZMnhwWlc1MFgyNWhiV1VpT2lKMWJtdHViM2R1SWl3aWVIQjJYMkZ6YzJWMFgybGtJam94Tnprek9UazBOemMyTVRFMU56TTJNU3dpWVhOelpYUmZZV2RsWDJSaGVYTWlPakFzSW5acFgzVnpaV05oYzJWZmFXUWlPakV3TURrNUxDSmtkWEpoZEdsdmJsOXpJam94TXl3aVltbDBjbUYwWlNJNk1UUXpPVEF3TkN3aWRYSnNaMlZ1WDNOdmRYSmpaU0k2SW5kM2R5SjkmY2NiPTE3LTEmX25jX2dpZD1zaVJFVGhySHNIZnhEdW1NZ25aVXdRJl9uY19zcz04Jl9uY196dD0yOCZvaD0wMF9BZnlPcVp0VWxDdjNYcjdCa3lPRzlOdnh6Y1NZNjZsRlBBbTdOYnlGSGJDQmp3Jm9lPTY5QjBCMDlEIiwiYXVkaW9fdXJsIjoiaHR0cHM6Ly92aWRlby16cmgxLTEueHguZmJjZG4ubmV0L28xL3YvdDIvZjIvbTc4L0FRTmRmaExmTWV1Q0pzZnV2T0c0SF8wSzhQY3BSUU1iUDdpVmZYV2FhRmtxVE50eVBQaHBWSllyeEw1elhmU216YlN2OUh3eWJiYVRMdlpQWk55R2QxcnJZQ29iMzctS3NsRmcyN2MubXA0P19uY19jYXQ9MTA4Jl9uY19zaWQ9OWNhMDUyJl9uY19odD12aWRlby16cmgxLTEueHguZmJjZG4ubmV0Jl9uY19vaGM9U1JobDBDeFE4VW9RN2tOdndHYnlhUXQmZWZnPWV5SjJaVzVqYjJSbFgzUmhaeUk2SW1SaGMyaGZiRzVmYUdWaFlXTmZkbUp5TTE5aGRXUnBieUlzSW5acFpHVnZYMmxrSWpveE5UWTRNakkyTURZM056TTJOelUxTENKdmFXeGZkWEpzWjJWdVgyRndjRjlwWkNJNk1Dd2lZMnhwWlc1MFgyNWhiV1VpT2lKMWJtdHViM2R1SWl3aWVIQjJYMkZ6YzJWMFgybGtJam94Tnprek9UazBOemMyTVRFMU56TTJNU3dpWVhOelpYUmZZV2RsWDJSaGVYTWlPakFzSW5acFgzVnpaV05oYzJWZmFXUWlPakV3TURrNUxDSmtkWEpoZEdsdmJsOXpJam94TXl3aVltbDBjbUYwWlNJNk5qWXpPREFzSW5WeWJHZGxibDl6YjNWeVkyVWlPaUozZDNjaWZRJTNEJTNEJmNjYj0xNy0xJl9uY19naWQ9c2lSRVRockhzSGZ4RHVtTWduWlV3USZfbmNfc3M9OCZfbmNfenQ9Mjgmb2g9MDBfQWZ6R0JxM2I3bHpnTGg1Y1poWFpjazRvRU5DNTJHdG5mVTVmbUdUY2FYT0liUSZvZT02OUFDQkQ4NSIsImlkIjoiMTU2ODIyNjA2NzczNjc1NSIsImZpbGVuYW1lIjoiU25hcFNhdmVfQXBwXzE1NjgyMjYwNjc3MzY3NTVfMTA4MHAubXA0In0.3rUj4eUmqKLYJDFl1EfcJBxkZ0KpSlWVQm2JCdxJa0U";
async function run() {
const tokenPayload = url.split("token=")[1].split(".")[1];
const decoded = Buffer.from(tokenPayload, 'base64').toString('utf8');
console.log(JSON.parse(decoded));
}
run();
+521
View File
@@ -0,0 +1,521 @@
# Sosyal Medya İndirici - Mimari Plan
> **Tarih:** 2026-03-05
> **Öncelik:** Yüksek
> **Karmaşıklık:** Düşük-Orta
> **Yaklaşım:** Basit ve Minimal
---
## 📋 Özet
Sosyal medya platformlarından (Instagram, YouTube, TikTok, X, Facebook vb.) video/post indirme özelliği. Kullanıcı bir URL girer, sistem içeriği tespit eder ve indirir.
### Basitleştirilmiş Gereksinimler
- ❌ Auth/giriş sistemi **YOK** (herkes kullanabilir)
- ✅ Basit IP-based rate limiting (abuse önleme)
- ✅ Public Cobalt API kullanımı
- ✅ Minimal ve temiz UI
---
## 🎯 Gereksinimler
### Fonksiyonel Gereksinimler
- URL input ile çoklu platform desteği
- Tek tıkla indirme
- Video kalite seçimi (mümkünse)
- İndirme ilerleme durumu
- Hata yönetimi ve kullanıcı bildirimleri
### Fonksiyonel Olmayan Gereksinimler
- **Güvenlik:** Rate limiting, input validation, abuse prevention
- **Performans:** Async processing, streaming downloads
- **UX:** Responsive design, dark/light mode, i18n support
- **Reliability:** Error recovery, timeout handling
---
## 🏗️ Mimari Tasarım
### High-Level Architecture
```mermaid
flowchart TB
subgraph Frontend[Frontend - Next.js]
UI[Download Page]
Form[URL Input Form]
Progress[Progress Indicator]
Result[Download Result]
end
subgraph API[Next.js API Routes]
Validate[Input Validation]
Detect[Platform Detection]
RateLimit[Rate Limiter]
Download[Download Handler]
end
subgraph External[External Services]
Cobalt[Cobalt API]
YtDlp[yt-dlp wrapper]
Direct[Direct URL Fetch]
end
UI --> Form
Form --> Validate
Validate --> RateLimit
RateLimit --> Detect
Detect --> Download
Download --> Cobalt
Download --> YtDlp
Download --> Direct
Cobalt --> Result
YtDlp --> Result
Direct --> Result
```
---
## 🔐 Güvenlik Katmanları
### 1. Input Validation & Sanitization
```typescript
// src/lib/utils/url-validator.ts
const ALLOWED_DOMAINS = [
"youtube.com",
"youtu.be",
"instagram.com",
"instagr.am",
"tiktok.com",
"vm.tiktok.com",
"twitter.com",
"x.com",
"facebook.com",
"fb.watch",
"vimeo.com",
"pinterest.com",
];
export function validateUrl(url: string): ValidationResult {
// 1. URL format check
// 2. Protocol check (https only)
// 3. Domain whitelist check
// 4. XSS prevention
// 5. SQL injection prevention
}
```
### 2. Rate Limiting
```typescript
// src/lib/security/rate-limiter.ts
const RATE_LIMIT_CONFIG = {
anonymous: { requests: 5, window: 3600 }, // 5 requests/hour
authenticated: { requests: 20, window: 3600 }, // 20 requests/hour
premium: { requests: 100, window: 3600 }, // 100 requests/hour
};
```
**Stratejiler:**
- IP-based limiting (Redis/Memory store)
- User-based limiting (auth token)
- Global rate limit (DDoS prevention)
### 3. Abuse Prevention
- **URL Pattern Blocking:** Malicious patterns engelleme
- **Bot Detection:** User-agent analysis
- **Request Fingerprinting:** Browser fingerprinting
- **CAPTCHA:** Şüpheli aktivitede CAPTCHA tetikleme
### 4. Content Security
- **Virus Scanning:** İndirilen dosyaları tara
- **File Size Limits:** Maksimum dosya boyutu
- **Content-Type Validation:** Sadece izin verilen formatlar
---
## 📁 Dosya Yapısı
```
src/
├── app/
│ ├── [locale]/
│ │ └── (site)/
│ │ └── download/
│ │ └── page.tsx # Download page
│ └── api/
│ └── download/
│ ├── route.ts # Main download endpoint
│ └── info/
│ └── route.ts # Get video info endpoint
├── components/
│ └── site/
│ └── download/
│ ├── download-form.tsx # URL input form
│ ├── download-result.tsx # Result display
│ ├── platform-badge.tsx # Platform indicator
│ ├── quality-selector.tsx # Quality options
│ └── download-progress.tsx # Progress indicator
├── lib/
│ ├── api/
│ │ └── download/
│ │ ├── types.ts # TypeScript interfaces
│ │ ├── service.ts # API service functions
│ │ └── use-hooks.ts # React Query hooks
│ ├── download/
│ │ ├── platform-detector.ts # URL -> Platform mapping
│ │ ├── cobalt-client.ts # Cobalt API client
│ │ └── download-manager.ts # Download orchestration
│ └── security/
│ ├── rate-limiter.ts # Rate limiting logic
│ ├── url-validator.ts # URL validation
│ └── abuse-detector.ts # Abuse detection
└── types/
└── download.ts # Shared types
```
---
## 🔌 API Endpoints
### POST /api/download
**Request:**
```typescript
interface DownloadRequest {
url: string; // Sosyal medya URL
quality?: "high" | "medium" | "low";
format?: "mp4" | "mp3" | "webm";
}
```
**Response:**
```typescript
interface DownloadResponse {
success: boolean;
data?: {
downloadUrl: string;
filename: string;
fileSize: number;
platform: Platform;
metadata: VideoMetadata;
};
error?: {
code: ErrorCode;
message: string;
};
}
```
### POST /api/download/info
**Request:**
```typescript
interface InfoRequest {
url: string;
}
```
**Response:**
```typescript
interface InfoResponse {
success: boolean;
data?: {
platform: Platform;
title: string;
thumbnail: string;
duration: number;
qualities: QualityOption[];
};
}
```
---
## 🌐 Platform Detection Algorithm
```typescript
// src/lib/download/platform-detector.ts
interface PlatformConfig {
name: Platform;
domains: string[];
patterns: RegExp[];
extractor: ExtractorType;
}
const PLATFORM_CONFIGS: PlatformConfig[] = [
{
name: "youtube",
domains: ["youtube.com", "youtu.be", "music.youtube.com"],
patterns: [
/youtube\.com\/watch\?v=[\w-]+/,
/youtu\.be\/[\w-]+/,
/youtube\.com\/shorts\/[\w-]+/,
],
extractor: "cobalt",
},
{
name: "instagram",
domains: ["instagram.com", "instagr.am"],
patterns: [/instagram\.com\/(p|reel|tv)\/[\w-]+/],
extractor: "cobalt",
},
{
name: "tiktok",
domains: ["tiktok.com", "vm.tiktok.com"],
patterns: [/tiktok\.com\/@[\w.]+\/video\/\d+/, /vm\.tiktok\.com\/[\w-]+/],
extractor: "cobalt",
},
{
name: "twitter",
domains: ["twitter.com", "x.com"],
patterns: [/twitter\.com\/\w+\/status\/\d+/, /x\.com\/\w+\/status\/\d+/],
extractor: "cobalt",
},
{
name: "facebook",
domains: ["facebook.com", "fb.watch"],
patterns: [/facebook\.com\/.*\/videos\/\d+/, /fb\.watch\/[\w-]+/],
extractor: "cobalt",
},
];
export function detectPlatform(url: string): Platform | null {
const urlObj = new URL(url);
for (const config of PLATFORM_CONFIGS) {
if (config.domains.some((d) => urlObj.hostname.includes(d))) {
return config.name;
}
}
return null;
}
```
---
## 🎨 UI Component Hierarchy
```mermaid
flowchart TD
Page[DownloadPage] --> Form[DownloadForm]
Page --> Result[DownloadResult]
Form --> Input[URLInput]
Form --> PlatformBadge[PlatformBadge]
Form --> QualitySelector[QualitySelector]
Form --> SubmitButton[SubmitButton]
Result --> Progress[DownloadProgress]
Result --> SuccessResult[SuccessResult]
Result --> ErrorResult[ErrorResult]
Progress --> ProgressBar[ProgressBar]
Progress --> StatusText[StatusText]
SuccessResult --> FilePreview[FilePreview]
SuccessResult --> DownloadButton[DownloadButton]
ErrorResult --> ErrorIcon[ErrorIcon]
ErrorResult --> ErrorMessage[ErrorMessage]
ErrorResult --> RetryButton[RetryButton]
```
---
## 🔄 Veri Akışı
```mermaid
sequenceDiagram
participant User
participant Frontend
participant API as Next.js API
participant Validator
participant RateLimiter
participant Cobalt as Cobalt API
User->>Frontend: URL Gir + İndir
Frontend->>Frontend: URL Format Validation
Frontend->>API: POST /api/download/info
API->>Validator: Validate URL
Validator-->>API: Valid
API->>RateLimiter: Check Rate Limit
RateLimiter-->>API: Allowed
API->>Cobalt: Get Video Info
Cobalt-->>API: Video Metadata
API-->>Frontend: Info Response
Frontend->>User: Show Options
User->>Frontend: Select Quality
Frontend->>API: POST /api/download
API->>Cobalt: Request Download
Cobalt-->>API: Download URL
API-->>Frontend: Download Response
Frontend->>User: Download File
```
---
## 🛡️ Güvenlik Checklist
### Input Security
- [ ] URL format validation
- [ ] Protocol whitelist (https only)
- [ ] Domain whitelist
- [ ] XSS prevention (DOMPurify)
- [ ] SQL injection prevention
- [ ] Path traversal prevention
### Rate Limiting
- [ ] IP-based limiting
- [ ] User-based limiting (authenticated)
- [ ] Global request limiting
- [ ] Sliding window algorithm
### Content Security
- [ ] File type validation
- [ ] File size limits
- [ ] Virus/malware scanning (optional)
- [ ] Content-Disposition headers
### API Security
- [ ] CSRF protection
- [ ] Content-Type validation
- [ ] Request size limits
- [ ] Timeout handling
---
## 📊 Error Handling Strategy
| Error Code | Message | User Action |
| ---------------------- | -------------------------- | ---------------------------- |
| `INVALID_URL` | Geçersiz URL formatı | URL'yi kontrol et |
| `UNSUPPORTED_PLATFORM` | Bu platform desteklenmiyor | Desteklenen platformları gör |
| `RATE_LIMIT_EXCEEDED` | Çok fazla istek | Bekle veya giriş yap |
| `VIDEO_NOT_FOUND` | Video bulunamadı | URL'yi kontrol et |
| `VIDEO_PRIVATE` | Video özel/gizli | Herkese açık video kullan |
| `DOWNLOAD_FAILED` | İndirme başarısız | Tekrar dene |
| `FILE_TOO_LARGE` | Dosya çok büyük | Düşük kalite seç |
---
## 🧪 Test Strategy
### Unit Tests
- URL validator fonksiyonları
- Platform detector logic
- Rate limiter calculations
- API service functions
### Integration Tests
- API endpoint tests
- External API mock tests
- Error scenario tests
### E2E Tests
- Complete download flow
- Error handling UI
- Rate limit scenarios
---
## 📦 Dependencies
### Production
```json
{
"axios": "^1.x",
"zod": "^3.x"
}
```
### Development
```json
{
"@types/node": "^20",
"vitest": "^1.x",
"msw": "^2.x"
}
```
---
## 🚀 Implementation Phases
### Phase 1: Core Infrastructure
1. URL validation ve platform detection
2. API route structure
3. Basic UI components
### Phase 2: External API Integration
1. Cobalt API client
2. Download orchestration
3. Error handling
### Phase 3: Security Layer
1. Rate limiting implementation
2. Abuse detection
3. Input sanitization
### Phase 4: UI/UX Polish
1. Progress indicators
2. Responsive design
3. Dark/light mode
4. i18n translations
### Phase 5: Testing & Optimization
1. Unit tests
2. Integration tests
3. Performance optimization
---
## 💡 Önemli Notlar
1. **Cobalt API:** Açık kaynaklı, self-hosted bir sosyal medya indirici API. Kullanımı için:
- Self-hosted instance (önerilen)
- Public instance (rate limit riski)
2. **Copyright Uyarısı:** Kullanıcılara telif hakkı uyarısı gösterilmeli
3. **Terms of Service:** Her platformun ToS'u kontrol edilmeli
4. **Monitoring:** API kullanımı ve hata oranları izlenmeli
---
## ❓ Açık Sorular
1. Cobalt API self-hosted mu kullanılacak yoksa public instance mi?
2. Video maksimum boyutu ne olacak?
3. Oturum açmamış kullanıcılar için limit ne kadar?
4. İndirilen dosyalar sunucuda saklanacak mı (cache)?
BIN
View File
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+2 -1
View File
@@ -27,9 +27,10 @@ export default async function RootLayout({
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'> <html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
<head> <head>
<link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' /> <link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' /> <link rel='icon' type='image/png' sizes='96x96' href='/favicon/favicon-96x96.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' /> <link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' />
<link rel='manifest' href='/favicon/site.webmanifest' /> <link rel='manifest' href='/favicon/site.webmanifest' />
<title>Sosyal Medya Post İndirme</title>
</head> </head>
<body className={bricolage.variable}> <body className={bricolage.variable}>
<NextIntlClientProvider> <NextIntlClientProvider>
+184 -3
View File
@@ -1,5 +1,186 @@
import { redirect } from 'next/navigation'; /**
* Home Page - Video Downloader
* Main page for social media video downloads
*/
export default async function Page() { 'use client';
redirect('/home');
import * as React from 'react';
import { useState, useCallback, useEffect } from 'react';
import { Box, Container, Heading, Text, VStack, Flex } from '@chakra-ui/react';
import { FiDownloadCloud } from 'react-icons/fi';
import { useTranslations } from 'next-intl';
import { DownloadForm } from '@/components/site/download/download-form';
import { DownloadResult } from '@/components/site/download/download-result';
import { detectPlatform } from '@/lib/download/platform-detector';
import { Platform, DownloadResponse, AudioFormat } from '@/types/download';
import { toaster } from '@/components/ui/feedback/toaster';
export default function HomePage() {
const t = useTranslations('download');
const [url, setUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [detectedPlatform, setDetectedPlatform] = useState<Platform | null>(null);
const [result, setResult] = useState<DownloadResponse | null>(null);
// Detect platform when URL changes
useEffect(() => {
if (url.trim()) {
const detection = detectPlatform(url);
setDetectedPlatform(detection.platform);
} else {
setDetectedPlatform(null);
}
}, [url]);
// Handle download submission
const handleSubmit = useCallback(async (
inputUrl: string,
audioFormat?: AudioFormat,
videoQuality?: string,
audioBitrate?: string
) => {
setUrl(inputUrl);
setIsLoading(true);
setResult(null);
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: inputUrl,
audioFormat: audioFormat,
quality: videoQuality,
audioBitrate: audioBitrate,
}),
});
const data = await response.json();
setResult(data);
// Show error toaster if request failed
if (!data.success && data.error) {
toaster.error({
title: t('errors.downloadFailed'),
description: data.error.message,
});
}
} catch (error) {
console.error('Download error:', error);
const errorResponse = {
success: false,
error: {
code: 'NETWORK_ERROR',
message: 'Network error. Please check your connection.',
},
};
setResult(errorResponse);
// Show error toaster
toaster.error({
title: t('errors.networkError'),
description: errorResponse.error.message,
});
} finally {
setIsLoading(false);
}
}, [t]);
// Handle URL change for platform detection
const handleUrlChange = useCallback((newUrl: string) => {
setUrl(newUrl);
}, []);
// Reset state
const handleReset = useCallback(() => {
setResult(null);
setUrl('');
setDetectedPlatform(null);
}, []);
return (
<Container maxW="container.md" py={{ base: 8, md: 16 }}>
<VStack gap={8} align="stretch">
{/* Header */}
<VStack gap={4} textAlign="center">
<Flex
w={16}
h={16}
borderRadius="full"
bg="primary.100"
_dark={{ bg: 'primary.900' }}
align="center"
justify="center"
mx="auto"
>
<Box as={FiDownloadCloud} boxSize={8} color="primary.500" />
</Flex>
<Heading size={{ base: 'xl', md: '2xl' }} fontWeight="bold">
{t('title')}
</Heading>
<Text fontSize="lg" color="fg.muted" maxW="md" mx="auto">
{t('subtitle')}
</Text>
</VStack>
{/* Main Card */}
<Box
p={{ base: 4, md: 8 }}
borderRadius="2xl"
bg="bg.panel"
border="1px solid"
borderColor="border.emphasized"
boxShadow={{ base: 'none', md: 'lg' }}
>
<VStack gap={6} align="stretch">
{/* Form or Result */}
{result ? (
<DownloadResult result={result} onReset={handleReset} />
) : (
<DownloadForm
onSubmit={handleSubmit}
onUrlChange={handleUrlChange}
isLoading={isLoading}
detectedPlatform={detectedPlatform}
/>
)}
</VStack>
</Box>
{/* Help Section */}
<VStack gap={4} textAlign="center">
<Text fontSize="sm" color="fg.muted">
{t('supportedPlatforms')}
</Text>
<Flex
gap={6}
wrap="wrap"
justify="center"
fontSize="sm"
color="fg.subtle"
>
<Text>YouTube</Text>
<Text>Instagram</Text>
<Text>TikTok</Text>
<Text>X (Twitter)</Text>
<Text>Facebook</Text>
</Flex>
</VStack>
{/* Disclaimer */}
<Text
fontSize="xs"
color="fg.muted"
textAlign="center"
maxW="md"
mx="auto"
>
{t('disclaimer')}
</Text>
</VStack>
</Container>
);
} }
+184
View File
@@ -0,0 +1,184 @@
/**
* Download API Route
* Handles social media video download requests
*
* Supports:
* - YouTube: MP4 video, MP3 audio
* - Instagram: Video/Image
* - TikTok: Video
* - Twitter/X: Video
* - Facebook: Video
*/
import { NextRequest, NextResponse } from "next/server";
import { validateUrl } from "@/lib/security/url-validator";
import { withRateLimit } from "@/lib/security/rate-limiter";
import { detectPlatform } from "@/lib/download/platform-detector";
import { fetchVideo, fetchYoutubeAudio, fetchYoutubeVideo } from "@/lib/download/downloader";
import { DownloadRequest, DownloadResponse } from "@/types/download";
/**
* POST /api/download
* Downloads a social media video
*/
export async function POST(
request: NextRequest,
): Promise<NextResponse<DownloadResponse>> {
console.log("========== DOWNLOAD API START ==========");
try {
// 1. Rate limiting check
console.log("[1] Checking rate limit...");
const rateLimitResult = withRateLimit(request);
if (!rateLimitResult.success) {
console.log("[1] Rate limit exceeded:", rateLimitResult.error);
return NextResponse.json(
{
success: false,
error: {
code: "RATE_LIMITED",
message: rateLimitResult.error,
},
},
{
status: 429,
headers: {
"Retry-After": rateLimitResult.retryAfter.toString(),
},
},
);
}
console.log("[1] Rate limit passed");
// 2. Parse request body
console.log("[2] Parsing request body...");
let body: DownloadRequest;
try {
body = await request.json();
console.log("[2] Request body:", JSON.stringify(body, null, 2));
} catch (parseError) {
console.log("[2] Failed to parse body:", parseError);
return NextResponse.json(
{
success: false,
error: {
code: "INVALID_BODY",
message: "Invalid request body",
},
},
{ status: 400 },
);
}
// 3. Validate URL
console.log("[3] Validating URL:", body.url);
const validation = validateUrl(body.url);
console.log("[3] Validation result:", JSON.stringify(validation, null, 2));
if (!validation.valid) {
console.log("[3] URL validation failed:", validation.error);
return NextResponse.json(
{
success: false,
error: {
code: "INVALID_URL",
message: validation.error || "Invalid URL",
},
},
{ status: 400 },
);
}
console.log("[3] URL is valid, sanitized:", validation.sanitizedUrl);
// 4. Detect platform
console.log("[4] Detecting platform...");
const platformResult = detectPlatform(validation.sanitizedUrl!);
console.log("[4] Platform detected:", JSON.stringify(platformResult, null, 2));
if (platformResult.platform === "unknown") {
console.log("[4] Unknown platform");
return NextResponse.json(
{
success: false,
error: {
code: "UNSUPPORTED_PLATFORM",
message:
"Unsupported platform. Supported: YouTube, Instagram, TikTok, Twitter/X, Facebook",
},
},
{ status: 400 },
);
}
// 5. Fetch video/media using appropriate downloader
console.log("[5] Fetching media...");
console.log("[5] Sending URL:", validation.sanitizedUrl);
console.log("[5] Platform:", platformResult.platform);
console.log("[5] Audio format requested:", body.audioFormat);
let downloadResponse: DownloadResponse;
// YouTube: Support both MP4 video and MP3 audio with quality options
if (platformResult.platform === "youtube") {
// If audioFormat is specified, download as MP3 audio
if (body.audioFormat) {
console.log("[5] Downloading YouTube audio (MP3)...");
console.log("[5] Audio bitrate:", body.audioBitrate);
downloadResponse = await fetchYoutubeAudio(validation.sanitizedUrl!, body.audioBitrate);
} else {
console.log("[5] Downloading YouTube video (MP4)...");
console.log("[5] Video quality:", body.quality);
downloadResponse = await fetchYoutubeVideo(validation.sanitizedUrl!, body.quality);
}
} else {
// Other platforms: Use default fetchVideo
downloadResponse = await fetchVideo({
url: validation.sanitizedUrl!,
quality: body.quality,
});
}
console.log("[5] Download response:", JSON.stringify(downloadResponse, null, 2));
// 6. Handle errors
if (!downloadResponse.success || downloadResponse.error) {
console.log("[6] Download failed:", downloadResponse.error);
return NextResponse.json(
{
success: false,
error: downloadResponse.error || {
code: "DOWNLOAD_FAILED",
message: "Failed to download video",
},
},
{ status: 400 },
);
}
// 7. Return success response
console.log("[7] Success! Returning download URL");
console.log("========== DOWNLOAD API END (SUCCESS) ==========");
return NextResponse.json({
success: true,
data: downloadResponse.data,
});
} catch (error) {
console.error("========== DOWNLOAD API ERROR ==========");
console.error("Error type:", error?.constructor?.name);
console.error("Error message:", error instanceof Error ? error.message : String(error));
console.error("Error stack:", error instanceof Error ? error.stack : "No stack trace");
console.error("=========================================");
return NextResponse.json(
{
success: false,
error: {
code: "INTERNAL_ERROR",
message: error instanceof Error ? error.message : "An unexpected error occurred",
},
},
{ status: 500 },
);
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
/**
* GET /api/proxy
* Proxies cross-origin URLs to enforce a 'Save As' download in the browser.
*
* Query parameters:
* - url: The remote URL target (e.g. googlevideo.com mp4 link)
* - filename: The desired file name for the downloaded file
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
const { searchParams } = new URL(request.url);
const url = searchParams.get("url");
let filename = searchParams.get("filename") || "download";
if (!url) {
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
}
// Ensure safe filenames
filename = encodeURIComponent(filename).replace(/['()]/g, escape).replace(/\*/g, '%2A');
try {
const requestHeaders: Record<string, string> = {
// Some servers like YT check User-Agent.
"User-Agent": request.headers.get("User-Agent") || "Mozilla/5.0",
};
// Forward the Range header if the browser sends it for pausing/resuming
if (request.headers.has("range")) {
requestHeaders["Range"] = request.headers.get("range")!;
}
const remoteResponse = await fetch(url, {
method: "GET",
headers: requestHeaders,
});
if (!remoteResponse.ok && remoteResponse.status !== 206) {
throw new Error(`Failed to fetch remote resource: ${remoteResponse.status}`);
}
// Forward the content type or fallback
const contentType = remoteResponse.headers.get("Content-Type") || "application/octet-stream";
const contentLength = remoteResponse.headers.get("Content-Length");
const acceptRanges = remoteResponse.headers.get("Accept-Ranges");
const contentRange = remoteResponse.headers.get("Content-Range");
// Prepare headers for the proxied response to enforce download
const responseHeaders = new Headers();
responseHeaders.set("Content-Type", contentType);
// Explicitly set Content-Disposition to attachment so the browser downloads the file
responseHeaders.set("Content-Disposition", `attachment; filename*=UTF-8''${filename}`);
// Transfer content headers if known, useful for download progress bars and resuming
if (contentLength) responseHeaders.set("Content-Length", contentLength);
if (acceptRanges) responseHeaders.set("Accept-Ranges", acceptRanges);
if (contentRange) responseHeaders.set("Content-Range", contentRange);
// Next.js (Edge/App Router) stream support
return new NextResponse(remoteResponse.body, {
status: remoteResponse.status,
headers: responseHeaders,
});
} catch (error) {
console.error("[PROXY API] Proxy download failed:", error);
return NextResponse.json(
{ error: "Target file could not be proxied for download" },
{ status: 500 }
);
}
}
@@ -0,0 +1,243 @@
/**
* Download Form Component
* URL input form for social media video downloads
* Supports MP4 video and MP3 audio for YouTube
*/
'use client';
import * as React from 'react';
import { useState, useCallback, useEffect } from 'react';
import { Box, Flex, Text, VStack, Spinner, HStack, ButtonGroup } from '@chakra-ui/react';
import { FiDownload, FiLink, FiX, FiYoutube, FiInstagram, FiMusic, FiVideo } from 'react-icons/fi';
import { FaTiktok, FaFacebook, FaTwitter } from 'react-icons/fa';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/buttons/button';
import { InputGroup } from '@/components/ui/forms/input-group';
import { Field } from '@/components/ui/forms/field';
import { Input } from '@chakra-ui/react';
import { IconButton } from '@chakra-ui/react';
import { Platform, PLATFORM_INFO, AudioFormat, VideoQuality } from '@/types/download';
import { NativeSelectRoot, NativeSelectField } from '@/components/ui/forms/native-select';
interface DownloadFormProps {
onSubmit: (url: string, audioFormat?: AudioFormat, videoQuality?: VideoQuality, audioBitrate?: "320" | "256" | "128" | "64") => Promise<void>;
onUrlChange?: (url: string) => void;
isLoading: boolean;
detectedPlatform: Platform | null;
}
const YOUTUBE_VIDEO_QUALITIES = [
{ value: '1080', label: '1080p (FHD)' },
{ value: '720', label: '720p (HD)' },
{ value: '480', label: '480p' },
{ value: '360', label: '360p' },
];
const YOUTUBE_AUDIO_BITRATES = [
{ value: '320', label: '320 kbps (High)' },
{ value: '256', label: '256 kbps' },
{ value: '128', label: '128 kbps (Normal)' },
{ value: '92', label: '92 kbps (Low)' },
];
export function DownloadForm({ onSubmit, onUrlChange, isLoading, detectedPlatform }: DownloadFormProps) {
const t = useTranslations('download');
const [url, setUrl] = useState('');
const [error, setError] = useState<string | null>(null);
const [downloadingFormat, setDownloadingFormat] = useState<'video' | 'audio' | null>(null);
// Quality states
const [videoQuality, setVideoQuality] = useState<VideoQuality>('720');
const [audioBitrate, setAudioBitrate] = useState<"320" | "256" | "128" | "64" | "92">('128');
const handleDownload = useCallback(async (format: 'video' | 'audio') => {
if (!url.trim()) {
setError(t('errors.urlRequired'));
return;
}
setError(null);
setDownloadingFormat(format);
// Pass audio format if downloading audio from YouTube
const audioFormat = format === 'audio' ? 'mp3' : undefined;
const vQuality = format === 'video' ? videoQuality : undefined;
const aBitrate = format === 'audio' ? (audioBitrate as "320" | "256" | "128" | "64") : undefined;
await onSubmit(url.trim(), audioFormat, vQuality, aBitrate);
setDownloadingFormat(null);
}, [url, onSubmit, t, videoQuality, audioBitrate]);
const handleClear = useCallback(() => {
setUrl('');
setError(null);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isLoading && url.trim()) {
handleDownload('video');
}
},
[handleDownload, isLoading, url]
);
const getPlatformIcon = (platform: Platform) => {
switch (platform) {
case 'youtube':
return FiYoutube;
case 'instagram':
return FiInstagram;
case 'tiktok':
return FaTiktok;
case 'twitter':
return FaTwitter;
case 'facebook':
return FaFacebook;
default:
return FiLink;
}
};
const PlatformIcon = detectedPlatform ? getPlatformIcon(detectedPlatform) : FiLink;
return (
<Box as="form" onSubmit={(e) => e.preventDefault()}>
<VStack gap={4} align="stretch">
<Field errorText={error || undefined} >
<InputGroup
width={'100%'}
startElement={
isLoading ? (
<Spinner size="sm" color="primary.500" />
) : (
<Box as={PlatformIcon} color={detectedPlatform ? PLATFORM_INFO[detectedPlatform]?.color : 'gray.500'} />
)
}
endElement={
url && !isLoading ? (
<IconButton aria-label="Clear" variant="ghost" size="xs" onClick={handleClear}>
<Box as={FiX} />
</IconButton>
) : undefined
}
>
<Input
placeholder={t('placeholder')}
value={url}
onChange={(e) => {
const newUrl = e.target.value;
setUrl(newUrl);
setError(null);
onUrlChange?.(newUrl);
}}
onKeyDown={handleKeyDown}
borderRadius="xl"
disabled={isLoading}
size={'xl'}
/>
</InputGroup>
</Field>
{/* YouTube: MP4 and MP3 buttons side by side */}
{detectedPlatform === 'youtube' ? (
<VStack gap={4} align="stretch" width="full">
<Flex gap={4} direction={{ base: 'column', sm: 'row' }} width="full">
{/* Video Download Section */}
<VStack align="stretch" flex={1} gap={2} p={4} borderWidth="1px" borderRadius="xl" borderColor="border.subtle" bg="bg.muted">
<Text fontSize="sm" fontWeight="medium" color="fg.muted">Video Quality</Text>
<NativeSelectRoot size="md" disabled={isLoading}>
<NativeSelectField
value={videoQuality}
onChange={(e) => setVideoQuality(e.currentTarget.value as VideoQuality)}
items={YOUTUBE_VIDEO_QUALITIES}
/>
</NativeSelectRoot>
<Button
type="button"
size="md"
colorPalette="primary"
fontWeight="semibold"
onClick={() => handleDownload('video')}
loading={downloadingFormat === 'video'}
disabled={isLoading || !url.trim()}
mt={2}
>
<Box as={FiVideo} mr={2} />
MP4 Video
</Button>
</VStack>
{/* Audio Download Section */}
<VStack align="stretch" flex={1} gap={2} p={4} borderWidth="1px" borderRadius="xl" borderColor="border.subtle" bg="bg.muted">
<Text fontSize="sm" fontWeight="medium" color="fg.muted">Audio Quality</Text>
<NativeSelectRoot size="md" disabled={isLoading}>
<NativeSelectField
value={audioBitrate}
onChange={(e) => setAudioBitrate(e.currentTarget.value as any)}
items={YOUTUBE_AUDIO_BITRATES}
/>
</NativeSelectRoot>
<Button
type="button"
size="md"
colorPalette="green"
fontWeight="semibold"
onClick={() => handleDownload('audio')}
loading={downloadingFormat === 'audio'}
disabled={isLoading || !url.trim()}
mt={2}
>
<Box as={FiMusic} mr={2} />
MP3 Audio
</Button>
</VStack>
</Flex>
</VStack>
) : (
/* Other platforms: Single download button */
<Button
type="submit"
size="lg"
width="full"
colorPalette="primary"
borderRadius="xl"
fontWeight="semibold"
onClick={() => handleDownload('video')}
loading={isLoading}
disabled={isLoading || !url.trim()}
>
<Box as={FiDownload} mr={2} />
{isLoading ? t('downloading') : t('download')}
</Button>
)}
<Flex justify="center" gap={4} wrap="wrap">
{['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'].map((platform) => {
const IconComponent = getPlatformIcon(platform as Platform);
return (
<Flex
key={platform}
align="center"
gap={1}
opacity={detectedPlatform === platform ? 1 : 0.5}
transition="opacity 0.2s"
>
<Box
as={IconComponent}
color={PLATFORM_INFO[platform as Platform]?.color}
boxSize={4}
/>
<Text fontSize="xs" color="fg.muted">
{PLATFORM_INFO[platform as Platform]?.name}
</Text>
</Flex>
);
})}
</Flex>
</VStack>
</Box>
);
}
@@ -0,0 +1,130 @@
/**
* Download Result Component
* Displays download result (success or error)
*/
'use client';
import * as React from 'react';
import { Box, Flex, Text, VStack, Image, HStack } from '@chakra-ui/react';
import { FiCheck, FiDownload, FiAlertCircle, FiX } from 'react-icons/fi';
import { useTranslations } from 'next-intl';
import { Alert } from '@/components/ui/feedback/alert';
import { Button } from '@/components/ui/buttons/button';
import { LinkButton } from '@/components/ui/buttons/link-button';
import { Tag } from '@/components/ui/data-display/tag';
import { Platform, PLATFORM_INFO, DownloadResponse } from '@/types/download';
interface DownloadResultProps {
result: DownloadResponse | null;
onReset: () => void;
}
export function DownloadResult({ result, onReset }: DownloadResultProps) {
const t = useTranslations('download');
if (!result) return null;
// Error state
if (!result.success || !result.data) {
return (
<Alert status="error" borderRadius="xl" variant="surface" icon={<Box as={FiAlertCircle} />}>
<Text fontWeight="medium">{t('error')}</Text>
<Text fontSize="sm">{result.error?.message || t('unknownError')}</Text>
<Button size="sm" variant="outline" onClick={onReset} ml="auto">
<Box as={FiX} mr={1} />
{t('newDownload')}
</Button>
</Alert>
);
}
const { data } = result;
return (
<Box
p={6}
borderRadius="xl"
bg="bg.subtle"
border="1px solid"
borderColor="border.emphasized"
>
<VStack gap={4} align="stretch">
{/* Success Header */}
<Flex align="center" gap={3}>
<Flex
w={10}
h={10}
borderRadius="full"
bg="green.100"
_dark={{ bg: 'green.900' }}
align="center"
justify="center"
>
<Box as={FiCheck} color="green.600" boxSize={5} />
</Flex>
<Box>
<Text fontWeight="semibold" fontSize="lg">
{t('readyToDownload')}
</Text>
<Text fontSize="sm" color="fg.muted">
{data.title || data.filename}
</Text>
</Box>
</Flex>
{/* Platform Badge */}
{data.platform && data.platform !== 'unknown' && (
<HStack>
<Tag colorPalette="primary" variant="subtle" borderRadius="md" px={2} py={1}>
{PLATFORM_INFO[data.platform]?.name}
</Tag>
</HStack>
)}
{/* Thumbnail if available */}
{data.thumbnail && (
<Box borderRadius="lg" overflow="hidden" maxH="200px">
<Image
src={data.thumbnail}
alt={data.title || 'Video thumbnail'}
w="100%"
h="auto"
objectFit="cover"
/>
</Box>
)}
{/* Actions */}
<Flex gap={3} direction={{ base: 'column', sm: 'row' }}>
<LinkButton
href={`/api/proxy?url=${encodeURIComponent(data.downloadUrl)}&filename=${encodeURIComponent(data.filename)}`}
target="_blank"
rel="noopener noreferrer"
colorPalette="primary"
size="lg"
flex={1}
borderRadius="xl"
>
<Box as={FiDownload} mr={2} />
{t('downloadFile')}
</LinkButton>
<Button
variant="outline"
size="lg"
borderRadius="xl"
onClick={onReset}
>
<Box as={FiX} mr={2} />
{t('newDownload')}
</Button>
</Flex>
{/* Help text */}
<Text fontSize="xs" color="fg.muted" textAlign="center">
{t('downloadHint')}
</Text>
</VStack>
</Box>
);
}
+1 -2
View File
@@ -9,6 +9,5 @@ export type NavItem = {
}; };
export const NAV_ITEMS: NavItem[] = [ export const NAV_ITEMS: NavItem[] = [
{ label: "home", href: "/home", public: true }, { label: "home", href: "/", public: true },
{ label: "predictions", href: "/predictions", public: true },
]; ];
+354
View File
@@ -0,0 +1,354 @@
/**
* Cobalt API Client
* Handles communication with Cobalt API for social media downloads
*/
import {
CobaltResponse,
DownloadRequest,
VideoQuality,
AudioFormat,
Platform,
} from "@/types/download";
// Cobalt API instance URL (public instance - v8)
// NOTE: v7 API was shut down on Nov 11th 2024
const COBALT_API_URL =
process.env.COBALT_API_URL || "https://api.cobalt.tools/api/json";
// Request timeout in milliseconds
const REQUEST_TIMEOUT = 30000;
/**
* Maps our quality to Cobalt quality format
*/
function mapQuality(quality?: VideoQuality): string {
const qualityMap: Record<VideoQuality, string> = {
max: "max",
"1080": "1080",
"720": "720",
"480": "480",
"360": "360",
};
return quality ? qualityMap[quality] : "720";
}
/**
* Cobalt API request body
*/
interface CobaltRequestBody {
url: string;
vCodec?: "h264" | "av1" | "vp9";
vQuality?: string;
aFormat?: AudioFormat;
aBitrate?: "320" | "256" | "128" | "64";
isAudioOnly?: boolean;
isAudioMuted?: boolean;
dubBrowserLang?: boolean;
filenamePattern?: "classic" | "pretty" | "basic" | "nerdy";
twitterGif?: boolean;
tiktokFullAudio?: boolean;
tiktokH265?: boolean;
twitterXUrl?: boolean;
}
/**
* Makes a request to Cobalt API
*/
export async function fetchFromCobalt(
request: DownloadRequest,
): Promise<CobaltResponse> {
console.log("[COBALT] Starting fetch for URL:", request.url);
console.log("[COBALT] Using API URL:", COBALT_API_URL);
const requestBody: CobaltRequestBody = {
url: request.url,
vQuality: mapQuality(request.quality),
filenamePattern: "pretty",
aFormat: request.audioFormat || "mp3",
aBitrate: request.audioBitrate || "128",
tiktokFullAudio: true,
twitterXUrl: true,
};
console.log("[COBALT] Request body:", JSON.stringify(requestBody, null, 2));
try {
console.log("[COBALT] Sending POST request...");
const response = await fetch(COBALT_API_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
});
console.log("[COBALT] Response status:", response.status, response.statusText);
console.log("[COBALT] Response headers:", Object.fromEntries(response.headers.entries()));
const data = await response.json();
console.log("[COBALT] Response data:", JSON.stringify(data, null, 2));
if (!response.ok) {
console.log("[COBALT] Response not OK, handling error...");
// Handle Cobalt error response
if (data.error) {
console.log("[COBALT] Error from Cobalt:", data.error);
return {
error: {
code: data.error.code || "error.api.fetch.fail",
message: data.error.message || "Failed to fetch from Cobalt API",
},
};
}
return {
error: {
code: "error.api.fetch.fail",
message: `API error: ${response.status} ${response.statusText}`,
},
};
}
console.log("[COBALT] Success! Returning data");
return data as CobaltResponse;
} catch (error) {
console.error("[COBALT] Exception occurred:");
console.error("[COBALT] Error type:", error?.constructor?.name);
console.error("[COBALT] Error message:", error instanceof Error ? error.message : String(error));
if (error instanceof Error) {
if (error.name === "AbortError" || error.name === "TimeoutError") {
return {
error: {
code: "error.api.fetch.timeout",
message: "Request timed out. Please try again.",
},
};
}
return {
error: {
code: "error.api.fetch.fail",
message: error.message,
},
};
}
return {
error: {
code: "error.api.fetch.fail",
message: "An unexpected error occurred",
},
};
}
}
/**
* Gets video info from Cobalt API
* Note: Cobalt doesn't have a separate info endpoint, so we use the main endpoint
* and extract info from the response
*/
export async function getVideoInfo(
url: string,
): Promise<{
success: boolean;
data?: {
title?: string;
thumbnail?: string;
platform?: Platform;
};
error?: { code: string; message: string };
}> {
try {
// Make a request to get video info
const response = await fetchFromCobalt({ url });
if ("error" in response) {
return {
success: false,
error: {
code: response.error.code,
message: response.error.message || "Unknown error",
},
};
}
// Extract available info from Cobalt response
if (response.status === "redirect" || response.status === "stream") {
return {
success: true,
data: {
// Cobalt doesn't provide title/thumbnail in basic response
// These would need additional API calls or scraping
},
};
}
if (response.status === "picker" && response.picker) {
return {
success: true,
data: {
// Picker means multiple options (e.g., Instagram carousel)
thumbnail: response.picker[0]?.thumb,
},
};
}
return {
success: true,
};
} catch (error) {
return {
success: false,
error: {
code: "error.api.info.fail",
message:
error instanceof Error ? error.message : "Failed to get video info",
},
};
}
}
/**
* Parses Cobalt error codes to user-friendly messages
*/
export function parseCobaltError(
errorCode: string,
locale: string = "en",
): { title: string; description: string } {
const messages: Record<
string,
Record<string, { title: string; description: string }>
> = {
"error.api.link.invalid": {
en: { title: "Invalid URL", description: "The provided URL is not valid." },
tr: { title: "Geçersiz URL", description: "Sağlanan URL geçerli değil." },
},
"error.api.fetch.fail": {
en: {
title: "Fetch Failed",
description: "Could not fetch the content. Please try again.",
},
tr: {
title: "İndirme Başarısız",
description: "İçerik alınamadı. Lütfen tekrar deneyin.",
},
},
"error.api.content.unavailable": {
en: {
title: "Content Unavailable",
description: "This content is not available or has been removed.",
},
tr: {
title: "İçerik Mevcut Değil",
description: "Bu içerik mevcut değil veya kaldırılmış.",
},
},
"error.api.rate_exceeded": {
en: {
title: "Rate Limited",
description: "Too many requests. Please wait a moment and try again.",
},
tr: {
title: "Sınır Aşıldı",
description: "Çok fazla istek. Lütfen bekleyip tekrar deneyin.",
},
},
"error.api.fetch.rate": {
en: {
title: "Service Busy",
description: "The service is currently busy. Please try again later.",
},
tr: {
title: "Servis Meşgul",
description: "Servis şu anda meşgul. Lütfen daha sonra tekrar deneyin.",
},
},
"error.api.content.video_region": {
en: {
title: "Region Restricted",
description: "This video is not available in your region.",
},
tr: {
title: "Bölge Kısıtlaması",
description: "Bu video bölgenizde mevcut değil.",
},
},
"error.api.content.post.private": {
en: {
title: "Private Content",
description: "This content is private and cannot be downloaded.",
},
tr: {
title: "Özel İçerik",
description: "Bu içerik özel ve indirilemez.",
},
},
"error.api.fetch.short": {
en: {
title: "Link Expired",
description: "This link has expired. Please get a fresh link.",
},
tr: {
title: "Bağlantı Süresi Doldu",
description: "Bu bağlantının süresi doldu. Lütfen yeni bir bağlantı alın.",
},
},
"error.api.fetch.critical": {
en: {
title: "Critical Error",
description: "A critical error occurred. Please contact support if this persists.",
},
tr: {
title: "Kritik Hata",
description: "Kritik bir hata oluştu. Bu devam ederse destekle iletişime geçin.",
},
},
"error.api.fetch.timeout": {
en: {
title: "Request Timeout",
description: "The request took too long. Please try again.",
},
tr: {
title: "Zaman Aşımı",
description: "İstek çok uzun sürdü. Lütfen tekrar deneyin.",
},
},
};
const localeMessages = messages[errorCode] || messages["error.api.fetch.fail"];
return localeMessages[locale] || localeMessages["en"];
}
/**
* Gets direct download URL from Cobalt response
*/
export function getDownloadUrl(response: CobaltResponse): string | null {
console.log("[COBALT] Extracting download URL from response...");
if ("error" in response) {
console.log("[COBALT] Response has error, no download URL");
return null;
}
if (response.status === "redirect" && response.url) {
console.log("[COBALT] Found redirect URL:", response.url);
return response.url;
}
if (response.status === "stream" && response.url) {
console.log("[COBALT] Found stream URL:", response.url);
return response.url;
}
if (response.status === "picker" && response.picker?.length) {
console.log("[COBALT] Found picker URL:", response.picker[0].url);
return response.picker[0].url;
}
console.log("[COBALT] No download URL found in response");
return null;
}
+693
View File
@@ -0,0 +1,693 @@
/**
* Social Media Video Downloader using multiple packages
* Supports: Instagram, YouTube, TikTok, Twitter/X, Facebook
*
* - @vreden/youtube_scraper: YouTube (video/audio with quality options)
* - snapsave-media-downloader: Instagram, TikTok, Twitter/X
* - yt-dlp (global): Facebook (fallback)
*/
import { snapsave } from "snapsave-media-downloader";
import { spawn } from "child_process";
import {
DownloadRequest,
DownloadResponse,
Platform,
MediaType,
VideoQuality,
AudioFormat,
} from "@/types/download";
// Removed @vreden types
// ============================================
// SnapSave API response types
// ============================================
interface SnapSaveMedia {
url?: string;
thumbnail?: string;
type?: "video" | "image";
resolution?: string;
shouldRender?: boolean;
}
interface SnapSaveData {
description?: string;
preview?: string;
media?: SnapSaveMedia[];
}
interface SnapSaveResponse {
success: boolean;
data?: SnapSaveData;
message?: string;
}
// ============================================
// yt-dlp video info type
// ============================================
interface YtDlpVideoInfo {
id?: string;
title?: string;
uploader?: string;
uploader_id?: string;
thumbnail?: string;
duration?: number;
duration_string?: string;
view_count?: number;
like_count?: number;
description?: string;
formats?: YtDlpFormat[];
url?: string;
}
interface YtDlpFormat {
format_id?: string;
format_note?: string;
ext?: string;
url?: string;
acodec?: string;
vcodec?: string;
width?: number;
height?: number;
filesize?: number;
abr?: number;
}
// ============================================
// Platform Detection
// ============================================
/**
* Detects the platform from a URL
*/
export function detectPlatform(url: string): Platform {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase().replace(/^www\./, "");
if (
hostname === "youtube.com" ||
hostname === "youtu.be" ||
hostname.includes("youtube")
) {
return "youtube";
}
if (
hostname === "instagram.com" ||
hostname.includes("instagram")
) {
return "instagram";
}
if (
hostname === "tiktok.com" ||
hostname.includes("tiktok")
) {
return "tiktok";
}
if (
hostname === "twitter.com" ||
hostname === "x.com" ||
hostname.includes("twitter")
) {
return "twitter";
}
if (
hostname === "facebook.com" ||
hostname.includes("facebook") ||
hostname === "fb.watch"
) {
return "facebook";
}
return "unknown";
} catch {
return "unknown";
}
}
// ============================================
// Helper Functions
// ============================================
/**
* Detects media type from URL
*/
function detectMediaType(url: string): MediaType {
const lowerUrl = url.toLowerCase();
if (
lowerUrl.includes(".mp4") ||
lowerUrl.includes(".webm") ||
lowerUrl.includes(".mkv") ||
lowerUrl.includes("video")
) {
return "video";
}
if (
lowerUrl.includes(".mp3") ||
lowerUrl.includes(".m4a") ||
lowerUrl.includes(".wav") ||
lowerUrl.includes("audio")
) {
return "audio";
}
if (
lowerUrl.includes(".jpg") ||
lowerUrl.includes(".jpeg") ||
lowerUrl.includes(".png") ||
lowerUrl.includes(".gif") ||
lowerUrl.includes(".webp")
) {
return "image";
}
return "video"; // Default to video
}
/**
* Gets file extension from URL
*/
function getFileExtension(url: string, mediaType?: string): string {
const lowerUrl = url.toLowerCase();
if (lowerUrl.includes(".mp4")) return ".mp4";
if (lowerUrl.includes(".webm")) return ".webm";
if (lowerUrl.includes(".mkv")) return ".mkv";
if (lowerUrl.includes(".mp3")) return ".mp3";
if (lowerUrl.includes(".m4a")) return ".m4a";
if (lowerUrl.includes(".jpg") || lowerUrl.includes(".jpeg")) return ".jpg";
if (lowerUrl.includes(".png")) return ".png";
if (lowerUrl.includes(".gif")) return ".gif";
if (lowerUrl.includes(".webp")) return ".webp";
if (mediaType === "image") return ".jpg";
if (mediaType === "audio") return ".mp3";
return ".mp4";
}
/**
* Maps VideoQuality to numeric value for filtering
*/
function getQualityNumber(quality?: VideoQuality): number {
if (!quality || quality === "max") return 1080;
return parseInt(quality);
}
/**
* Run yt-dlp command and get JSON output
*/
async function runYtdlp(url: string, args: string[] = []): Promise<YtDlpVideoInfo> {
return new Promise((resolve, reject) => {
const defaultArgs = [
"--dump-single-json",
"--no-warnings",
"--no-check-certificates",
"--prefer-free-formats",
];
const allArgs = [...defaultArgs, ...args, url];
console.log("[YT-DLP] Running command: yt-dlp", allArgs.join(" "));
const process = spawn("yt-dlp", allArgs);
let stdout = "";
let stderr = "";
process.stdout.on("data", (data) => {
stdout += data.toString();
});
process.stderr.on("data", (data) => {
stderr += data.toString();
});
process.on("close", (code) => {
if (code !== 0) {
console.error("[YT-DLP] Error:", stderr);
reject(new Error(stderr || `yt-dlp exited with code ${code}`));
return;
}
try {
const info = JSON.parse(stdout) as YtDlpVideoInfo;
resolve(info);
} catch (parseError) {
reject(new Error("Failed to parse yt-dlp output"));
}
});
process.on("error", (error) => {
console.error("[YT-DLP] Spawn error:", error);
reject(error);
});
});
}
// ============================================
/**
* Download YouTube video via yt-dlp
*/
async function downloadYoutubeVideo(
url: string,
quality?: VideoQuality
): Promise<DownloadResponse> {
console.log("[YT-DLP-YOUTUBE] Downloading video:", url, "Quality:", quality);
try {
const targetHeight = getQualityNumber(quality);
const info = await runYtdlp(url);
// Find formats that have BOTH video and audio mixed into a single MP4 file.
// yt-dlp lists these as having vcodec and acodec
const videoFormats = info.formats?.filter(
(f: YtDlpFormat) =>
f.url &&
f.ext === "mp4" &&
f.vcodec !== "none" &&
f.acodec !== "none" &&
!f.url.includes(".m3u8") &&
!f.url.includes("/manifest/")
);
if (!videoFormats?.length) {
return {
success: false,
error: {
code: "error.download.no_result",
message: "Uygun video formatı bulunamadı.",
},
};
}
// Sort to find the requested quality or closest lower quality
const sortedFormats = videoFormats.sort((a, b) => (b.height || 0) - (a.height || 0));
let bestFormat = sortedFormats.find(f => (f.height || 0) <= targetHeight) || sortedFormats[0];
// Some 1080p combined might not exist (YouTube usually separates them), fallback gracefully
if (!bestFormat) {
bestFormat = sortedFormats[0];
}
const safeTitle = info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "youtube_video";
return {
success: true,
data: {
downloadUrl: bestFormat.url!,
filename: `${safeTitle}.mp4`,
platform: "youtube",
title: info.title,
thumbnail: info.thumbnail,
mediaType: "video",
duration: info.duration_string,
author: info.uploader,
views: info.view_count?.toString(),
},
};
} catch (error) {
console.error("[YT-DLP-YOUTUBE] Video download error:", error);
return {
success: false,
error: {
code: "error.download.failed",
message: error instanceof Error ? error.message : "YouTube video indirme hatası",
},
};
}
}
/**
* Download YouTube audio via yt-dlp
*/
async function downloadYoutubeAudio(
url: string,
format?: AudioFormat,
bitrate?: string
): Promise<DownloadResponse> {
console.log("[YT-DLP-YOUTUBE] Downloading audio:", url, "Bitrate:", bitrate);
try {
const targetBitrate = bitrate ? parseInt(bitrate) : 128;
const info = await runYtdlp(url);
// Find audio-only formats (m4a or webm/mp3 usually without vcodec)
const audioFormats = info.formats?.filter(
(f: YtDlpFormat) =>
f.url &&
f.vcodec === "none" &&
f.acodec !== "none" &&
(f.ext === "m4a" || f.ext === "webm") &&
!f.url.includes(".m3u8") &&
!f.url.includes("/manifest/")
);
if (!audioFormats?.length) {
return {
success: false,
error: {
code: "error.download.no_result",
message: "Uygun ses formatı bulunamadı.",
},
};
}
// We prefer higher audio bitrate equal or closest to requested.
const sortedFormats = audioFormats.sort((a, b) => (b.abr || 0) - (a.abr || 0));
let bestFormat = sortedFormats.find(f => (f.abr || 0) <= targetBitrate) || sortedFormats[0];
const safeTitle = info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "youtube_audio";
// YouTube's M4A is widely supported as direct download
const ext = bestFormat.ext || "m4a";
return {
success: true,
data: {
downloadUrl: bestFormat.url!,
filename: `${safeTitle}.${ext}`,
platform: "youtube",
title: info.title,
thumbnail: info.thumbnail,
mediaType: "audio",
duration: info.duration_string,
author: info.uploader,
views: info.view_count?.toString(),
},
};
} catch (error) {
console.error("[YT-DLP-YOUTUBE] Audio download error:", error);
return {
success: false,
error: {
code: "error.download.failed",
message: error instanceof Error ? error.message : "YouTube ses indirme hatası",
},
};
}
}
/**
* Get YouTube video metadata without downloading via yt-dlp
*/
async function getYoutubeMetadata(url: string): Promise<YtDlpVideoInfo | null> {
console.log("[YT-DLP-YOUTUBE] Getting metadata for:", url);
try {
const result = await runYtdlp(url);
return result || null;
} catch (error) {
console.error("[YT-DLP-YOUTUBE] Metadata error:", error);
return null;
}
}
// ============================================
// Facebook Download Functions (yt-dlp fallback)
// ============================================
// Removed obsolete runYtdlp (it is moved UP)
/**
* Download from Facebook using yt-dlp
*/
async function downloadFromFacebookYtDlp(url: string): Promise<DownloadResponse> {
console.log("[YT-DLP] Downloading Facebook:", url);
try {
const info = await runYtdlp(url);
console.log("[YT-DLP] Facebook info:", {
title: info.title,
uploader: info.uploader,
duration: info.duration_string,
});
// Find video format with URL
const videoFormats = info.formats?.filter(
(f: YtDlpFormat) => f.url && (f.ext === "mp4" || f.vcodec !== "none")
);
if (!videoFormats?.length) {
return {
success: false,
error: {
code: "error.download.no_result",
message: "Video formatı bulunamadı",
},
};
}
// Prefer higher quality
const bestFormat =
videoFormats.find(
(f: YtDlpFormat) =>
f.format_note?.includes("1080") || f.format_note?.includes("720")
) || videoFormats[0];
const title =
info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "facebook_video";
return {
success: true,
data: {
downloadUrl: bestFormat.url!,
filename: `${title}.mp4`,
platform: "facebook",
title: info.title,
thumbnail: info.thumbnail,
mediaType: "video",
duration: info.duration_string,
author: info.uploader,
views: info.view_count?.toString(),
},
};
} catch (error) {
console.error("[YT-DLP] Facebook error:", error);
return {
success: false,
error: {
code: "error.download.failed",
message: error instanceof Error ? error.message : "Facebook indirme hatası",
},
};
}
}
// ============================================
// SnapSave Download Functions (Instagram, TikTok, Twitter)
// ============================================
/**
* Download using SnapSave (Instagram, TikTok, Twitter)
*/
async function downloadWithSnapSave(
url: string,
platform: Platform
): Promise<DownloadResponse> {
console.log(`[SNAPSAVE] Downloading ${platform}:`, url);
try {
const result: SnapSaveResponse = await snapsave(url);
console.log(`[SNAPSAVE] ${platform} result:`, JSON.stringify(result, null, 2));
if (!result.success || !result.data?.media?.length) {
return {
success: false,
error: {
code: "error.download.no_result",
message: result.message || "İçerik bulunamadı",
},
};
}
// Get the best quality media (prefer HD video)
const media = result.data.media;
let selectedMedia: SnapSaveMedia | null = null;
// For videos, prefer HD quality
if (media.some((m) => m.type === "video")) {
// Sort by resolution (prefer higher quality)
const videos = media.filter((m) => m.type === "video");
// Prefer 1080p, then 720p, then HD, then any video
selectedMedia =
videos.find((m) => m.resolution?.includes("1080")) ||
videos.find((m) => m.resolution?.includes("720")) ||
videos.find((m) => m.resolution?.includes("HD")) ||
videos.find((m) => !m.shouldRender) ||
videos[0];
} else {
// For images, take the first one
selectedMedia = media[0];
}
if (!selectedMedia?.url) {
return {
success: false,
error: {
code: "error.download.no_result",
message: "Geçerli bir medya URL'si bulunamadı",
},
};
}
const mediaType = selectedMedia.type || detectMediaType(selectedMedia.url);
// Decode SnapSave render token if present so we download the real file instead of an HTML page
let finalDownloadUrl = selectedMedia.url;
if (finalDownloadUrl.includes("render.php?token=")) {
try {
const tokenParts = finalDownloadUrl.split("token=")[1]?.split(".");
if (tokenParts && tokenParts.length > 1) {
const payload = tokenParts[1];
const decoded = Buffer.from(payload, "base64").toString("utf8");
const parsed = JSON.parse(decoded);
// Prefer audio_url if mediaType is audio, otherwise video_url
if (mediaType === "audio" && parsed.audio_url) {
finalDownloadUrl = parsed.audio_url;
} else if (parsed.video_url) {
finalDownloadUrl = parsed.video_url;
}
}
} catch (e) {
console.error("[SNAPSAVE] Failed to decode render token", e);
}
}
const ext = getFileExtension(finalDownloadUrl, mediaType);
return {
success: true,
data: {
downloadUrl: finalDownloadUrl,
filename: `${platform}_${Date.now()}${ext}`,
platform,
title: result.data.description,
thumbnail: result.data.preview || selectedMedia.thumbnail,
mediaType,
},
};
} catch (error) {
console.error(`[SNAPSAVE] ${platform} error:`, error);
return {
success: false,
error: {
code: "error.download.failed",
message: error instanceof Error ? error.message : `${platform} indirme hatası`,
},
};
}
}
// ============================================
// Main Download Functions
// ============================================
/**
* Main download function
* Routes to platform-specific downloaders based on URL and request parameters
*/
export async function fetchVideo(request: DownloadRequest): Promise<DownloadResponse> {
console.log("[DOWNLOADER] Starting download for URL:", request.url);
console.log("[DOWNLOADER] Request parameters:", {
quality: request.quality,
audioFormat: request.audioFormat,
audioBitrate: request.audioBitrate,
});
const platform = detectPlatform(request.url);
console.log("[DOWNLOADER] Detected platform:", platform);
switch (platform) {
case "youtube":
// If audioFormat is specified, download as audio (MP3)
if (request.audioFormat) {
console.log("[DOWNLOADER] YouTube audio download requested");
return downloadYoutubeAudio(request.url, request.audioFormat, request.audioBitrate);
}
// Otherwise download as video (MP4)
console.log("[DOWNLOADER] YouTube video download requested");
return downloadYoutubeVideo(request.url, request.quality);
case "instagram":
return downloadWithSnapSave(request.url, platform);
case "tiktok":
return downloadWithSnapSave(request.url, platform);
case "twitter":
return downloadWithSnapSave(request.url, platform);
case "facebook":
console.log("[DOWNLOADER] Facebook video download requested");
const snapResult = await downloadWithSnapSave(request.url, platform);
if (snapResult.success) {
return snapResult;
}
console.log("[DOWNLOADER] SnapSave failed for Facebook, falling back to yt-dlp...");
return downloadFromFacebookYtDlp(request.url);
default:
return {
success: false,
error: {
code: "error.download.unsupported_platform",
message:
"Desteklenmeyen platform. Desteklenenler: YouTube, Instagram, TikTok, Twitter/X, Facebook",
},
};
}
}
/**
* Download YouTube video (MP4)
* @param url - YouTube URL or video ID
* @param quality - Video quality (144, 360, 480, 720, 1080)
*/
export async function fetchYoutubeVideo(
url: string,
quality?: VideoQuality
): Promise<DownloadResponse> {
return downloadYoutubeVideo(url, quality);
}
/**
* Download YouTube audio (MP3)
* @param url - YouTube URL or video ID
* @param bitrate - Audio bitrate (92, 128, 256, 320 kbps)
*/
export async function fetchYoutubeAudio(
url: string,
bitrate?: string
): Promise<DownloadResponse> {
return downloadYoutubeAudio(url, undefined, bitrate);
}
/**
* Gets video info without downloading
*/
export async function getVideoInfo(
url: string
): Promise<{ title?: string; thumbnail?: string; platform?: Platform }> {
const platform = detectPlatform(url);
if (platform === "youtube") {
const metadata = await getYoutubeMetadata(url);
if (metadata) {
return {
title: metadata.title,
thumbnail: metadata.thumbnail,
platform,
};
}
}
return { platform: platform || undefined };
}
// Re-export types for convenience
export type { DownloadRequest, DownloadResponse, Platform, MediaType };
+159
View File
@@ -0,0 +1,159 @@
/**
* Platform Detector
* Detects social media platform from URL
*/
import {
Platform,
PlatformDetectionResult,
SUPPORTED_DOMAINS,
} from "@/types/download";
/**
* Detects the social media platform from a URL
* @param url - The URL to detect platform from
* @returns PlatformDetectionResult with platform and confidence
*/
export function detectPlatform(url: string): PlatformDetectionResult {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase().replace("www.", "");
// Check each platform's domains
for (const [platform, domains] of Object.entries(SUPPORTED_DOMAINS) as [
Platform,
string[],
][]) {
if (platform === "unknown") continue;
for (const domain of domains) {
if (hostname === domain || hostname.endsWith("." + domain)) {
return {
platform,
confidence: "high",
originalUrl: url,
normalizedUrl: normalizeUrl(url, platform),
};
}
}
}
// Check for known URL patterns even if domain doesn't match exactly
const patternResult = detectByPattern(url);
if (patternResult) {
return patternResult;
}
return {
platform: "unknown",
confidence: "low",
originalUrl: url,
};
} catch {
return {
platform: "unknown",
confidence: "low",
originalUrl: url,
};
}
}
/**
* Detects platform by URL patterns
*/
function detectByPattern(url: string): PlatformDetectionResult | null {
const patterns: { pattern: RegExp; platform: Platform }[] = [
// YouTube patterns
{ pattern: /youtube\.com\/watch\?v=[\w-]+/i, platform: "youtube" },
{ pattern: /youtu\.be\/[\w-]+/i, platform: "youtube" },
{ pattern: /youtube\.com\/shorts\/[\w-]+/i, platform: "youtube" },
{ pattern: /youtube\.com\/embed\/[\w-]+/i, platform: "youtube" },
// Instagram patterns
{ pattern: /instagram\.com\/(p|reel|tv)\/[\w-]+/i, platform: "instagram" },
{ pattern: /instagr\.am\/(p|reel|tv)\/[\w-]+/i, platform: "instagram" },
// TikTok patterns
{ pattern: /tiktok\.com\/@[\w.]+\/video\/\d+/i, platform: "tiktok" },
{ pattern: /vm\.tiktok\.com\/[\w-]+/i, platform: "tiktok" },
{ pattern: /vt\.tiktok\.com\/[\w-]+/i, platform: "tiktok" },
// Twitter/X patterns
{ pattern: /twitter\.com\/\w+\/status\/\d+/i, platform: "twitter" },
{ pattern: /x\.com\/\w+\/status\/\d+/i, platform: "twitter" },
// Facebook patterns
{ pattern: /facebook\.com\/.*\/videos\/\d+/i, platform: "facebook" },
{ pattern: /facebook\.com\/watch\/?\?v=\d+/i, platform: "facebook" },
{ pattern: /fb\.watch\/[\w-]+/i, platform: "facebook" },
];
for (const { pattern, platform } of patterns) {
if (pattern.test(url)) {
return {
platform,
confidence: "high",
originalUrl: url,
};
}
}
return null;
}
/**
* Normalizes URL for specific platforms
* Some platforms need URL normalization for better API compatibility
*/
function normalizeUrl(url: string, platform: Platform): string {
try {
const urlObj = new URL(url);
switch (platform) {
case "youtube": {
// Convert youtu.be to youtube.com
if (urlObj.hostname === "youtu.be") {
const videoId = urlObj.pathname.slice(1);
return `https://www.youtube.com/watch?v=${videoId}`;
}
// Convert shorts to regular watch URL
if (urlObj.pathname.startsWith("/shorts/")) {
const videoId = urlObj.pathname.replace("/shorts/", "");
return `https://www.youtube.com/watch?v=${videoId}`;
}
return url;
}
case "tiktok": {
// Keep the original URL - Cobalt handles short URLs
return url;
}
case "twitter": {
// Ensure x.com URLs are handled properly
return url;
}
default:
return url;
}
} catch {
return url;
}
}
/**
* Checks if a platform is supported
*/
export function isPlatformSupported(platform: Platform): boolean {
return platform !== "unknown";
}
/**
* Gets the best URL to use for downloading
* Some platforms work better with normalized URLs
*/
export function getBestUrlForDownload(url: string): string {
const result = detectPlatform(url);
return result.normalizedUrl || result.originalUrl;
}
+220
View File
@@ -0,0 +1,220 @@
/**
* Simple Rate Limiter
* IP-based rate limiting for API endpoints
*/
import { NextRequest } from "next/server";
interface RateLimitEntry {
count: number;
resetTime: number;
blocked: boolean;
}
interface RateLimitConfig {
windowMs: number; // Time window in milliseconds
maxRequests: number; // Max requests per window
blockDurationMs: number; // How long to block after exceeding limit
}
// In-memory store (resets on server restart)
// For production, use Redis or similar
const rateLimitStore = new Map<string, RateLimitEntry>();
// Cleanup interval (every 10 minutes)
const CLEANUP_INTERVAL = 10 * 60 * 1000;
// Default rate limit config
const DEFAULT_CONFIG: RateLimitConfig = {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 20, // 20 requests per hour per IP
blockDurationMs: 30 * 60 * 1000, // 30 minutes block
};
// Stricter config for unauthenticated users
const PUBLIC_CONFIG: RateLimitConfig = {
windowMs: 60 * 60 * 1000, // 1 hour
maxRequests: 10, // 10 requests per hour
blockDurationMs: 60 * 60 * 1000, // 1 hour block
};
/**
* Cleanup old entries from the rate limit store
*/
function cleanupStore(): void {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (entry.resetTime < now && !entry.blocked) {
rateLimitStore.delete(key);
} else if (
entry.blocked &&
entry.resetTime < now - DEFAULT_CONFIG.blockDurationMs
) {
rateLimitStore.delete(key);
}
}
}
// Run cleanup periodically
if (typeof setInterval !== "undefined") {
setInterval(cleanupStore, CLEANUP_INTERVAL);
}
/**
* Gets client IP from request
*/
export function getClientIp(request: NextRequest): string {
// Try various headers that might contain the real IP
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
// x-forwarded-for can contain multiple IPs, first is the client
return forwarded.split(",")[0].trim();
}
const realIp = request.headers.get("x-real-ip");
if (realIp) {
return realIp;
}
// Fallback to a default (in development)
return "unknown";
}
/**
* Checks if an IP is rate limited
*/
export function checkRateLimit(
ip: string,
config: RateLimitConfig = DEFAULT_CONFIG,
): {
allowed: boolean;
remaining: number;
resetTime: number;
blocked: boolean;
} {
const now = Date.now();
const entry = rateLimitStore.get(ip);
// If no entry exists, create one
if (!entry) {
rateLimitStore.set(ip, {
count: 1,
resetTime: now + config.windowMs,
blocked: false,
});
return {
allowed: true,
remaining: config.maxRequests - 1,
resetTime: now + config.windowMs,
blocked: false,
};
}
// Check if currently blocked
if (entry.blocked) {
const blockEndTime = entry.resetTime + config.blockDurationMs;
if (now < blockEndTime) {
return {
allowed: false,
remaining: 0,
resetTime: blockEndTime,
blocked: true,
};
}
// Block expired, reset
entry.blocked = false;
entry.count = 0;
entry.resetTime = now + config.windowMs;
}
// Check if window has expired
if (now > entry.resetTime) {
entry.count = 1;
entry.resetTime = now + config.windowMs;
return {
allowed: true,
remaining: config.maxRequests - 1,
resetTime: entry.resetTime,
blocked: false,
};
}
// Check if limit exceeded
if (entry.count >= config.maxRequests) {
entry.blocked = true;
return {
allowed: false,
remaining: 0,
resetTime: entry.resetTime + config.blockDurationMs,
blocked: true,
};
}
// Increment count
entry.count++;
return {
allowed: true,
remaining: config.maxRequests - entry.count,
resetTime: entry.resetTime,
blocked: false,
};
}
/**
* Rate limit middleware for API routes
*/
export function withRateLimit(
request: NextRequest,
config: RateLimitConfig = PUBLIC_CONFIG,
): { success: true } | { success: false; error: string; retryAfter: number } {
const ip = getClientIp(request);
const result = checkRateLimit(ip, config);
if (!result.allowed) {
const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000);
return {
success: false,
error: result.blocked
? "Too many requests. Your IP has been temporarily blocked."
: "Rate limit exceeded. Please try again later.",
retryAfter,
};
}
return { success: true };
}
/**
* Gets remaining requests for an IP
*/
export function getRemainingRequests(
ip: string,
config: RateLimitConfig = DEFAULT_CONFIG,
): number {
const entry = rateLimitStore.get(ip);
if (!entry || Date.now() > entry.resetTime) {
return config.maxRequests;
}
return Math.max(0, config.maxRequests - entry.count);
}
/**
* Resets rate limit for an IP (admin use)
*/
export function resetRateLimit(ip: string): void {
rateLimitStore.delete(ip);
}
/**
* Gets current rate limit stats (admin use)
*/
export function getRateLimitStats(): { totalIps: number; blockedIps: number } {
let blockedIps = 0;
for (const entry of rateLimitStore.values()) {
if (entry.blocked) blockedIps++;
}
return {
totalIps: rateLimitStore.size,
blockedIps,
};
}
+204
View File
@@ -0,0 +1,204 @@
/**
* URL Validator
* Validates and sanitizes social media URLs
*/
import { Platform, SUPPORTED_DOMAINS } from "@/types/download";
export interface ValidationResult {
valid: boolean;
error?: string;
sanitizedUrl?: string;
platform?: Platform;
}
const DANGEROUS_PATTERNS = [
/javascript:/i,
/data:/i,
/vbscript:/i,
/file:/i,
/about:/i,
/blob:/i,
/\s+/g, // Whitespace
/<[^>]*>/g, // HTML tags
/%3C/gi, // Encoded <
/%3E/gi, // Encoded >
/%22/gi, // Encoded "
/%27/gi, // Encoded '
];
/**
* Validates a social media URL
* @param url - The URL to validate
* @returns ValidationResult with validation status and sanitized URL
*/
export function validateUrl(url: string): ValidationResult {
// 1. Check if URL is provided
if (!url || typeof url !== "string") {
return { valid: false, error: "URL is required" };
}
// 2. Trim whitespace
let sanitizedUrl = url.trim();
// 3. Check for dangerous patterns (XSS prevention)
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(sanitizedUrl)) {
return { valid: false, error: "Invalid URL format" };
}
}
// 4. Add protocol if missing
if (
!sanitizedUrl.startsWith("http://") &&
!sanitizedUrl.startsWith("https://")
) {
sanitizedUrl = "https://" + sanitizedUrl;
}
// 5. Parse and validate URL structure
let parsedUrl: URL;
try {
parsedUrl = new URL(sanitizedUrl);
} catch {
return { valid: false, error: "Invalid URL format" };
}
// 6. Enforce HTTPS only
if (parsedUrl.protocol !== "https:") {
// Allow http for localhost development
if (!parsedUrl.hostname.includes("localhost")) {
return { valid: false, error: "Only HTTPS URLs are allowed" };
}
}
// 7. Check for valid hostname
if (!parsedUrl.hostname || parsedUrl.hostname.length < 3) {
return { valid: false, error: "Invalid hostname" };
}
// 8. Check against supported domains
const hostname = parsedUrl.hostname.toLowerCase().replace("www.", "");
let detectedPlatform: Platform | null = null;
for (const [platform, domains] of Object.entries(SUPPORTED_DOMAINS) as [
Platform,
string[],
][]) {
if (platform === "unknown") continue;
for (const domain of domains) {
if (hostname === domain || hostname.endsWith("." + domain)) {
detectedPlatform = platform;
break;
}
}
if (detectedPlatform) break;
}
if (!detectedPlatform) {
return {
valid: false,
error:
"Unsupported platform. Supported: YouTube, Instagram, TikTok, Twitter/X, Facebook",
};
}
// 9. Final sanitization
sanitizedUrl = parsedUrl.toString();
return {
valid: true,
sanitizedUrl,
platform: detectedPlatform,
};
}
/**
* Sanitizes a URL by removing potentially dangerous characters
*/
export function sanitizeUrl(url: string): string {
return url
.trim()
.replace(/\s+/g, "")
.replace(/<[^>]*>/g, "")
.replace(/javascript:/gi, "")
.replace(/data:/gi, "")
.replace(/vbscript:/gi, "");
}
/**
* Checks if URL appears to be a valid social media video URL
*/
export function isVideoUrl(url: string): boolean {
const videoPatterns = [
/youtube\.com\/watch\?v=[\w-]+/i,
/youtu\.be\/[\w-]+/i,
/youtube\.com\/shorts\/[\w-]+/i,
/instagram\.com\/reel\/[\w-]+/i,
/instagram\.com\/tv\/[\w-]+/i,
/tiktok\.com\/@[\w.]+\/video\/\d+/i,
/vm\.tiktok\.com\/[\w-]+/i,
/twitter\.com\/\w+\/status\/\d+/i,
/x\.com\/\w+\/status\/\d+/i,
/facebook\.com\/.*\/videos\/\d+/i,
/fb\.watch\/[\w-]+/i,
];
return videoPatterns.some((pattern) => pattern.test(url));
}
/**
* Extracts video ID from URL (platform-specific)
*/
export function extractVideoId(url: string, platform: Platform): string | null {
try {
const urlObj = new URL(url);
switch (platform) {
case "youtube": {
// youtu.be/VIDEO_ID
if (urlObj.hostname === "youtu.be") {
return urlObj.pathname.slice(1);
}
// youtube.com/watch?v=VIDEO_ID
const vParam = urlObj.searchParams.get("v");
if (vParam) return vParam;
// youtube.com/shorts/VIDEO_ID
if (urlObj.pathname.startsWith("/shorts/")) {
return urlObj.pathname.replace("/shorts/", "");
}
return null;
}
case "instagram": {
// instagram.com/p/POST_ID or /reel/REEL_ID
const match = urlObj.pathname.match(/\/(p|reel|tv)\/([\w-]+)/);
return match ? match[2] : null;
}
case "tiktok": {
// tiktok.com/@user/video/VIDEO_ID
const match = urlObj.pathname.match(/\/video\/(\d+)/);
return match ? match[1] : null;
}
case "twitter": {
// twitter.com/user/status/TWEET_ID
const match = urlObj.pathname.match(/\/status\/(\d+)/);
return match ? match[1] : null;
}
case "facebook": {
// facebook.com/.../videos/VIDEO_ID
const match = urlObj.pathname.match(/\/videos\/(\d+)/);
return match ? match[1] : null;
}
default:
return null;
}
} catch {
return null;
}
}
+3 -39
View File
@@ -1,55 +1,19 @@
import { NAV_ITEMS } from "@/config/navigation";
import { withAuth } from "next-auth/middleware";
import createMiddleware from "next-intl/middleware"; import createMiddleware from "next-intl/middleware";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { routing } from "./i18n/routing"; import { routing } from "./i18n/routing";
const publicPages = NAV_ITEMS.flatMap((item) => [
...(!item.protected ? [item.href] : []),
...(item.children
?.filter((child) => !child.protected)
.map((child) => child.href) ?? []),
]);
const handleI18nRouting = createMiddleware(routing); const handleI18nRouting = createMiddleware(routing);
const authMiddleware = withAuth(
// Note that this callback is only invoked if
// the `authorized` callback has returned `true`
// and not for pages listed in `pages`.
function onSuccess(req) {
return handleI18nRouting(req);
},
{
callbacks: {
authorized: ({ token }) => token != null,
},
pages: {
signIn: "/home",
},
},
);
export default function proxy(req: NextRequest) { export default function proxy(req: NextRequest) {
// CRITICAL: Skip API routes entirely - they should not go through i18n or auth middleware // CRITICAL: Skip API routes entirely - they should not go through i18n middleware
if (req.nextUrl.pathname.startsWith("/api/")) { if (req.nextUrl.pathname.startsWith("/api/")) {
return; // Return undefined to pass through without modification return; // Return undefined to pass through without modification
} }
const publicPathnameRegex = RegExp( // All pages are public - no auth required
`^(/(${routing.locales.join("|")}))?(${publicPages.flatMap((p) => (p === "/" ? ["", "/"] : p)).join("|")})/?$`, return handleI18nRouting(req);
"i",
);
const isPublicPage = publicPathnameRegex.test(req.nextUrl.pathname);
if (isPublicPage) {
return handleI18nRouting(req);
} else {
return (authMiddleware as any)(req);
}
} }
export const config = { export const config = {
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)", matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
// matcher: ['/', '/(de|en|tr)/:path*'],
}; };
+33
View File
@@ -0,0 +1,33 @@
/**
* Type declarations for ab-downloader package
* @see https://www.npmjs.com/package/ab-downloader
*/
declare module "ab-downloader" {
export interface DownloadResult {
url: string;
title?: string;
thumbnail?: string;
duration?: number;
quality?: string;
developer?: string;
contactme?: string;
}
// All-in-one downloader (auto-detects platform)
export function aio(url: string): Promise<DownloadResult | DownloadResult[]>;
// Platform-specific downloaders
// Note: igdl returns an array
export function igdl(url: string): Promise<DownloadResult[]>; // Instagram
export function youtube(url: string): Promise<DownloadResult | DownloadResult[]>; // YouTube
export function ttdl(url: string): Promise<DownloadResult | DownloadResult[]>; // TikTok
export function twitter(url: string): Promise<DownloadResult | DownloadResult[]>; // Twitter/X
export function fbdown(url: string): Promise<DownloadResult | DownloadResult[]>; // Facebook
// Other downloaders
export function mediafire(url: string): Promise<DownloadResult | DownloadResult[]>;
export function capcut(url: string): Promise<DownloadResult | DownloadResult[]>;
export function gdrive(url: string): Promise<DownloadResult | DownloadResult[]>;
export function pinterest(url: string): Promise<DownloadResult | DownloadResult[]>;
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Type declarations for @distube/ytdl-core
* @see https://www.npmjs.com/package/@distube/ytdl-core
*/
declare module "@distube/ytdl-core" {
export interface VideoDetails {
title: string;
author?: {
name: string;
user?: string;
channel_url?: string;
};
lengthSeconds: string;
viewCount?: string;
thumbnails?: Array<{ url: string; width?: number; height?: number }>;
description?: string;
media?: Record<string, string>;
}
export interface Format {
itag: number;
url: string;
mimeType?: string;
quality?: string;
qualityLabel?: string;
audioQuality?: string;
hasVideo: boolean;
hasAudio: boolean;
container?: string;
codecs?: string;
width?: number;
height?: number;
fps?: number;
bitrate?: number;
audioBitrate?: number;
duration?: string;
}
export interface VideoInfo {
videoDetails: VideoDetails;
formats: Format[];
related_videos?: unknown[];
}
export function getInfo(url: string): Promise<VideoInfo>;
export function getInfo(videoId: string): Promise<VideoInfo>;
export function getURLVideoID(url: string): string;
export function getVideoID(url: string): string;
export function chooseFormat(
formats: Format[],
options?: { quality?: string; filter?: string }
): Format | undefined;
export function filterFormats(
formats: Format[],
filter: string
): Format[];
export function validateID(id: string): boolean;
export function validateURL(url: string): boolean;
const ytdl: {
(url: string, options?: unknown): NodeJS.ReadableStream;
getInfo: typeof getInfo;
getURLVideoID: typeof getURLVideoID;
getVideoID: typeof getVideoID;
chooseFormat: typeof chooseFormat;
filterFormats: typeof filterFormats;
validateID: typeof validateID;
validateURL: typeof validateURL;
};
export default ytdl;
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Download Feature Types
* Social Media Video Downloader
*/
// Supported platforms
export type Platform =
| "youtube"
| "instagram"
| "tiktok"
| "twitter"
| "facebook"
| "unknown";
// Video quality options
export type VideoQuality = "max" | "1080" | "720" | "480" | "360";
// Audio format options
export type AudioFormat = "mp3" | "opus" | "wav" | "best";
// API Request types
export interface DownloadRequest {
url: string;
quality?: VideoQuality;
audioFormat?: AudioFormat;
audioBitrate?: "320" | "256" | "128" | "64";
}
export interface InfoRequest {
url: string;
}
// API Response types
export interface CobaltErrorResponse {
error: {
code: CobaltErrorCode;
message?: string;
context?: {
service?: string;
};
};
}
export interface CobaltSuccessResponse {
status: "redirect" | "stream" | "picker";
url?: string;
picker?: PickerItem[];
filename?: string;
}
export type CobaltResponse = CobaltErrorResponse | CobaltSuccessResponse;
export interface PickerItem {
type: "video" | "photo";
url: string;
thumb?: string;
}
export type CobaltErrorCode =
| "error.api.link.invalid"
| "error.api.fetch.fail"
| "error.api.content.unavailable"
| "error.api.rate_exceeded"
| "error.api.fetch.rate"
| "error.api.content.video_region"
| "error.api.content.post.private"
| "error.api.fetch.short"
| "error.api.fetch.critical"
| "error.api.fetch.timeout"
| "error.api.info.fail";
// Media type
export type MediaType = 'video' | 'image' | 'audio';
// Our API Response types
export interface DownloadResponse {
success: boolean;
data?: {
downloadUrl: string;
filename: string;
platform: Platform;
title?: string;
thumbnail?: string;
mediaType?: MediaType;
duration?: string;
author?: string;
likes?: string;
views?: string;
};
error?: {
code: string;
message: string;
};
}
export interface InfoResponse {
success: boolean;
data?: {
platform: Platform;
title?: string;
thumbnail?: string;
duration?: number;
qualities?: VideoQuality[];
};
error?: {
code: string;
message: string;
};
}
// Platform detection result
export interface PlatformDetectionResult {
platform: Platform;
confidence: "high" | "low";
originalUrl: string;
normalizedUrl?: string;
}
// Rate limiting
export interface RateLimitInfo {
ip: string;
requests: number;
lastRequest: number;
blocked: boolean;
blockUntil?: number;
}
// Supported domains map
export const SUPPORTED_DOMAINS: Record<Platform, string[]> = {
youtube: [
"youtube.com",
"youtu.be",
"music.youtube.com",
"youtube-nocookie.com",
],
instagram: ["instagram.com", "instagr.am"],
tiktok: ["tiktok.com", "vm.tiktok.com", "vt.tiktok.com"],
twitter: ["twitter.com", "x.com", "t.co"],
facebook: ["facebook.com", "fb.watch", "fb.com"],
unknown: [],
};
// Platform display info
export const PLATFORM_INFO: Record<
Platform,
{ name: string; color: string; icon: string }
> = {
youtube: { name: "YouTube", color: "#FF0000", icon: "youtube" },
instagram: { name: "Instagram", color: "#E4405F", icon: "instagram" },
tiktok: { name: "TikTok", color: "#000000", icon: "tiktok" },
twitter: { name: "X (Twitter)", color: "#1DA1F2", icon: "twitter" },
facebook: { name: "Facebook", color: "#1877F2", icon: "facebook" },
unknown: { name: "Bilinmeyen", color: "#6B7280", icon: "question" },
};
+91
View File
@@ -0,0 +1,91 @@
/**
* Type declarations for ruhend-scraper
* @see https://www.npmjs.com/package/ruhend-scraper
*/
declare module "ruhend-scraper" {
// YouTube MP3 result
export interface Ytmp3Result {
title: string;
audio: string;
author: string;
description: string;
duration: string;
views: string;
upload: string;
thumbnail: string;
}
// YouTube MP4 result
export interface Ytmp4Result {
title: string;
audio: string;
author: string;
description: string;
duration: string;
views: string;
upload: string;
thumbnail: string;
}
// TikTok result
export interface TtdlResult {
title: string;
author: string;
username: string;
published: string;
like: string;
comment: string;
share: string;
views: string;
bookmark: string;
video: string;
cover: string;
music: string;
profilePicture: string;
}
// Instagram/Facebook media item
export interface MediaItem {
url: string;
}
// Instagram/Facebook response
export interface MediaResponse {
data: MediaItem[];
}
// YouTube search video result
export interface YtSearchVideo {
type: "video";
title: string;
url: string;
durationH: string;
publishedTime: string;
view: string;
thumbnail: string;
}
// YouTube search channel result
export interface YtSearchChannel {
type: "channel";
channelName: string;
url: string;
subscriberH: string;
videoCount: string;
}
// YouTube search result
export interface YtSearchResult {
video: YtSearchVideo[];
channel: YtSearchChannel[];
}
// Export functions
export function ytmp3(url: string): Promise<Ytmp3Result>;
export function ytmp4(url: string): Promise<Ytmp4Result>;
export function ttdl(url: string): Promise<TtdlResult>;
export function igdl(url: string): Promise<MediaResponse>;
export function fbdl(url: string): Promise<MediaResponse>;
export function ytsearch(query: string): Promise<YtSearchResult>;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Type declarations for test-downloader
* Social media downloader package
*/
declare module "test-downloader" {
export interface TestDownloaderResult {
developer?: string;
title?: string;
url?: string | Array<{ hd?: string; sd?: string } | Record<string, unknown>>;
thumbnail?: string;
video?: string[];
audio?: string[];
HD?: string;
Normal_video?: string;
status?: string;
message?: string;
}
export function rebelaldwn(url: string): Promise<TestDownloaderResult>;
export function rebelfbdown(url: string): Promise<TestDownloaderResult>;
export function rebelinstadl(url: string): Promise<TestDownloaderResult>;
export function rebeltiktokdl(url: string): Promise<TestDownloaderResult>;
export function rebeltwitter(url: string): Promise<TestDownloaderResult>;
export function rebelyt(url: string): Promise<TestDownloaderResult>;
export function rebelpindl(url: string): Promise<TestDownloaderResult>;
export function rebelcapcutdl(url: string): Promise<TestDownloaderResult>;
export function rebellikeedl(url: string): Promise<TestDownloaderResult>;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Type declarations for @vreden/youtube_scraper
* YouTube video downloader for audio and video formats
*/
declare module "@vreden/youtube_scraper" {
// Metadata types
export interface VredenAuthor {
name?: string;
url?: string;
}
export interface VredenDuration {
seconds: number;
timestamp: string;
}
export interface VredenMetadata {
type?: "video";
videoId?: string;
url?: string;
title?: string;
description?: string;
image?: string;
thumbnail?: string;
seconds?: number;
timestamp?: string;
duration?: VredenDuration;
ago?: string;
views?: number;
author?: VredenAuthor;
}
// Download types
export interface VredenDownload {
status?: boolean;
quality?: string;
availableQuality?: number[];
url?: string;
filename?: string;
}
// Result type
export interface VredenYoutubeResult {
status?: boolean;
creator?: string;
metadata?: VredenMetadata;
download?: VredenDownload;
}
// Channel metadata types
export interface VredenChannelThumbnail {
quality?: string;
url?: string;
width?: number;
height?: number;
}
export interface VredenChannelStatistics {
view?: string;
video?: string;
subscriber?: string;
}
export interface VredenChannelResult {
id?: string;
title?: string;
description?: string;
username?: string;
thumbnails?: VredenChannelThumbnail[];
banner?: string;
published_date?: string;
published_format?: string;
statistics?: VredenChannelStatistics;
}
// Search result types
export interface VredenSearchResult {
type?: "video" | "channel" | "playlist";
videoId?: string;
title?: string;
description?: string;
thumbnail?: string;
duration?: string;
views?: number;
author?: VredenAuthor;
url?: string;
}
/**
* Download YouTube audio as MP3
* @param link - YouTube URL or video ID
* @param quality - Audio quality in kbps (92, 128, 256, 320)
*/
export function ytmp3(
link: string,
quality?: 92 | 128 | 256 | 320
): Promise<VredenYoutubeResult>;
/**
* Download YouTube video as MP4
* @param link - YouTube URL or video ID
* @param quality - Video quality in pixels (144, 360, 480, 720, 1080)
*/
export function ytmp4(
link: string,
quality?: 144 | 360 | 480 | 720 | 1080
): Promise<VredenYoutubeResult>;
/**
* Alternative MP3 downloader using api.vreden.my.id
* @param link - YouTube URL or video ID
* @param quality - Audio quality in kbps (92, 128, 256, 320)
*/
export function apimp3(
link: string,
quality?: 92 | 128 | 256 | 320
): Promise<VredenYoutubeResult>;
/**
* Alternative MP4 downloader using api.vreden.my.id
* @param link - YouTube URL or video ID
* @param quality - Video quality in pixels (144, 360, 480, 720, 1080)
*/
export function apimp4(
link: string,
quality?: 144 | 360 | 480 | 720 | 1080
): Promise<VredenYoutubeResult>;
/**
* Search YouTube for videos, channels, and playlists
* @param query - Search query string
*/
export function search(query: string): Promise<VredenSearchResult[]>;
/**
* Fetch detailed video metadata
* @param link - YouTube video URL or ID
*/
export function metadata(link: string): Promise<VredenMetadata>;
/**
* Fetch channel metadata
* @param usernameOrUrl - YouTube channel URL or username (@handle or custom URL)
*/
export function channel(usernameOrUrl: string): Promise<VredenChannelResult>;
}
+1 -1
View File
File diff suppressed because one or more lines are too long